From 6432a6673283491dc5fd65d4aaea716fbf680de0 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Wed, 25 Feb 2026 21:22:49 +0800 Subject: [PATCH] Add remove plugin and formatting code CLI (#1088) --- CONTRIBUTING.md | 8 +- backend/app/admin/service/plugin_service.py | 20 +-- backend/cli.py | 137 +++++++++++++++----- backend/migrate.sh | 5 - backend/plugin/installer.py | 35 +++++ backend/scripts/format.sh | 2 +- pre-commit.sh | 3 - 7 files changed, 151 insertions(+), 59 deletions(-) delete mode 100644 backend/migrate.sh delete mode 100644 pre-commit.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a40fe1de..d9ee2a48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ Go to the root directory of the project, open the terminal, and run the following command: ```sh - uv sync + uv run fba init --auto ``` 3. Checkout @@ -31,10 +31,8 @@ 4. Format and Lint - Auto-formatting and lint via `prek` - ```shell - prek run --all-files + fba format ``` 5. Commit and push @@ -55,7 +53,7 @@ - `migrate.sh`: Perform automatic database migration -- `scripts/format.sh`: Perform ruff format check +- `scripts/format.sh`: Perform ruff format with preview - `scripts/lint.sh`: Perform prek formatting diff --git a/backend/app/admin/service/plugin_service.py b/backend/app/admin/service/plugin_service.py index 339bb2b4..0ede9452 100644 --- a/backend/app/admin/service/plugin_service.py +++ b/backend/app/admin/service/plugin_service.py @@ -1,21 +1,19 @@ import io import json -import os -import shutil -import zipfile from typing import Any import anyio from fastapi import UploadFile +from starlette.concurrency import run_in_threadpool from backend.common.enums import PluginType, StatusType from backend.common.exception import errors from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR from backend.database.redis import redis_client -from backend.plugin.installer import install_git_plugin, install_zip_plugin +from backend.plugin.installer import install_git_plugin, install_zip_plugin, remove_plugin, zip_plugin from backend.plugin.requirements import uninstall_requirements_async from backend.utils.timezone import timezone @@ -72,8 +70,9 @@ class PluginService: if not await plugin_dir.exists(): raise errors.NotFoundError(msg='插件不存在') await uninstall_requirements_async(plugin) - bacup_dir = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup' - shutil.move(plugin_dir, bacup_dir) + backup_file = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup.zip' + await run_in_threadpool(zip_plugin, plugin_dir, backup_file) + await run_in_threadpool(remove_plugin, plugin_dir) await redis_client.delete(f'{settings.PLUGIN_REDIS_PREFIX}:{plugin}') await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true') @@ -112,14 +111,7 @@ class PluginService: raise errors.NotFoundError(msg='插件不存在') bio = io.BytesIO() - with zipfile.ZipFile(bio, 'w') as zf: - for root, dirs, files in os.walk(plugin_dir): - dirs[:] = [d for d in dirs if d != '__pycache__'] - for file in files: - file_path = os.path.join(root, file) - arcname = os.path.relpath(file_path, start=plugin_dir) # noqa: ASYNC240 - zf.write(file_path, os.path.join(plugin, arcname)) - + await run_in_threadpool(zip_plugin, plugin_dir, bio) bio.seek(0) return bio diff --git a/backend/cli.py b/backend/cli.py index 31b52d2b..89738f60 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -19,6 +19,7 @@ from rich.table import Table from rich.text import Text from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession +from starlette.concurrency import run_in_threadpool from watchfiles import Change, PythonFilter from backend import __version__ @@ -31,6 +32,7 @@ from backend.core.path_conf import ( ENV_EXAMPLE_FILE_PATH, ENV_FILE_PATH, MYSQL_SCRIPT_DIR, + PLUGIN_DIR, POSTGRESQL_SCRIPT_DIR, RELOAD_LOCK_FILE, ) @@ -42,12 +44,15 @@ from backend.database.db import ( ) from backend.database.redis import RedisCli, redis_client from backend.plugin.core import get_plugin_sql, get_plugins -from backend.plugin.installer import install_git_plugin, install_zip_plugin +from backend.plugin.installer import install_git_plugin, install_zip_plugin, zip_plugin +from backend.plugin.installer import remove_plugin as _remove_plugin +from backend.plugin.requirements import uninstall_requirements_async from backend.utils.console import console from backend.utils.dynamic_import import import_module_cached from backend.utils.sql_parser import parse_sql_script +from backend.utils.timezone import timezone -output_help = '\n更多信息,尝试 "[cyan]--help[/]"' +output_help = "\n更多信息,尝试 '[cyan]--help[/]'" class CustomReloadFilter(PythonFilter): @@ -374,6 +379,52 @@ async def install_plugin( raise cappa.Exit(e.msg if isinstance(e, BaseExceptionError) else str(e), code=1) +async def remove_plugin(plugin: str | None) -> None: + if settings.ENVIRONMENT != 'dev': + raise cappa.Exit('插件卸载仅在开发环境可用', code=1) + + async def remove() -> None: + plugin_dir = PLUGIN_DIR / plugin + if not plugin_dir.exists(): + raise cappa.Exit(f'插件 {plugin} 不存在', code=1) + + console.print(f'正在卸载插件 {plugin} 依赖...', style='white') + await uninstall_requirements_async(plugin) + + console.print(f'正在备份插件 {plugin}...', style='white') + backup_file = PLUGIN_DIR / f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup.zip' + await run_in_threadpool(zip_plugin, plugin_dir, backup_file) + await run_in_threadpool(_remove_plugin, plugin_dir) + + console.print(f'备份文件:{backup_file}', style='white') + console.print(f'插件 {plugin} 卸载成功', style='bold green') + console.print('\n请根据插件说明(README.md)移除相关配置并重启服务', style='yellow') + + plugins = get_plugins() + if not plugins: + raise cappa.Exit('当前没有已安装的插件', code=1) + + if not plugin: + table = Table(show_header=True, header_style='bold magenta') + table.add_column('编号', style='cyan', no_wrap=True, justify='center') + table.add_column('插件名称', style='green', no_wrap=True) + + for idx, name in enumerate(plugins, 1): + table.add_row(str(idx), name) + + console.print(table) + choice = IntPrompt.ask('请选择要卸载的插件编号', choices=[str(i) for i in range(1, len(plugins) + 1)]) + plugin = plugins[choice - 1] + else: + if plugin not in plugins: + raise cappa.Exit(f'插件 {plugin} 不存在', code=1) + + try: + await remove() + except Exception as e: + raise cappa.Exit(f'插件卸载失败:{e}', code=1) + + async def get_sql_scripts() -> list[str]: sql_scripts = [] db_script_dir = MYSQL_SCRIPT_DIR if DataBaseType.mysql == settings.DATABASE_TYPE else POSTGRESQL_SCRIPT_DIR @@ -549,6 +600,58 @@ class Run: run(host=self.host, port=self.port, reload=self.no_reload, workers=self.workers) +@cappa.command(help='新增插件', default_long=True) +@dataclass +class Add: + path: Annotated[ + str | None, + cappa.Arg(help='ZIP 插件的本地完整路径'), + ] + repo_url: Annotated[ + str | None, + cappa.Arg(help='Git 插件的仓库地址'), + ] + no_sql: Annotated[ + bool, + cappa.Arg(default=False, help='禁用插件 SQL 脚本自动执行'), + ] + db_type: Annotated[ + DataBaseType, + cappa.Arg(default='postgresql', help='执行插件 SQL 脚本的数据库类型'), + ] + pk_type: Annotated[ + PrimaryKeyType, + cappa.Arg(default='autoincrement', help='执行插件 SQL 脚本数据库主键类型'), + ] + + async def __call__(self) -> None: + await install_plugin(self.path, self.repo_url, self.no_sql, self.db_type, self.pk_type) + + +@cappa.command(help='移除插件') +@dataclass +class Remove: + plugin: Annotated[ + str | None, + cappa.Arg(default=None, help='要移除的插件名称'), + ] + + async def __call__(self) -> None: + await remove_plugin(self.plugin) + + +@cappa.command(help='格式化代码') +@dataclass +class Format: + def __call__(self) -> None: + try: + subprocess.run(['prek', 'run', '--all-files'], cwd=BASE_PATH.parent, check=False) + except FileNotFoundError: + raise cappa.Exit('prek 未安装,请先安装项目依赖', code=1) + except KeyboardInterrupt: + pass + + @cappa.command(help='从当前主机启动 Celery worker 服务', default_long=True) @dataclass class Worker: @@ -595,34 +698,6 @@ class Celery: subcmd: cappa.Subcommands[Worker | Beat | Flower] -@cappa.command(help='新增插件', default_long=True) -@dataclass -class Add: - path: Annotated[ - str | None, - cappa.Arg(help='ZIP 插件的本地完整路径'), - ] - repo_url: Annotated[ - str | None, - cappa.Arg(help='Git 插件的仓库地址'), - ] - no_sql: Annotated[ - bool, - cappa.Arg(default=False, help='禁用插件 SQL 脚本自动执行'), - ] - db_type: Annotated[ - DataBaseType, - cappa.Arg(default='postgresql', help='执行插件 SQL 脚本的数据库类型'), - ] - pk_type: Annotated[ - PrimaryKeyType, - cappa.Arg(default='autoincrement', help='执行插件 SQL 脚本数据库主键类型'), - ] - - async def __call__(self) -> None: - await install_plugin(self.path, self.repo_url, self.no_sql, self.db_type, self.pk_type) - - @cappa.command(help='导入代码生成业务和模型列', default_long=True) @dataclass class Import: @@ -780,7 +855,7 @@ class FbaCli: str, cappa.Arg(value_name='PATH', default='', show_default=False, help='在事务中执行 SQL 脚本'), ] - subcmd: cappa.Subcommands[Init | Run | Add | Alembic | Celery | CodeGenerator | None] = None + subcmd: cappa.Subcommands[Init | Run | Add | Remove | Format | Celery | CodeGenerator | Alembic | None] = None async def __call__(self) -> None: if self.sql: diff --git a/backend/migrate.sh b/backend/migrate.sh deleted file mode 100644 index 9574f48f..00000000 --- a/backend/migrate.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -alembic revision --autogenerate - -alembic upgrade head diff --git a/backend/plugin/installer.py b/backend/plugin/installer.py index 5a615b72..80504de2 100644 --- a/backend/plugin/installer.py +++ b/backend/plugin/installer.py @@ -1,6 +1,7 @@ import io import os import re +import stat import zipfile import anyio @@ -136,3 +137,37 @@ async def install_git_plugin(repo_url: str) -> str: await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'true') return repo_name + + +def remove_plugin(plugin_dir: os.PathLike) -> None: + """ + 删除插件 + + :param plugin_dir: 插件目录 + :return: + """ + import shutil + + def _on_error(func, path, _exc_info) -> None: # noqa: ANN001 + os.chmod(path, stat.S_IWRITE) + func(path) + + shutil.rmtree(plugin_dir, onerror=_on_error) + + +def zip_plugin(plugin_dir: os.PathLike, target: os.PathLike | io.BytesIO) -> None: + """ + zip 压缩插件 + + :param plugin_dir: 插件目录 + :param target: 压缩目标 + :return: + """ + with zipfile.ZipFile(target, 'w') as zf: + plugin_dir_parent = os.path.dirname(plugin_dir) + for root, dirs, files in os.walk(plugin_dir): + dirs[:] = [d for d in dirs if d != '__pycache__'] + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, start=plugin_dir_parent) + zf.write(file_path, arcname) diff --git a/backend/scripts/format.sh b/backend/scripts/format.sh index 816bab2a..7741d5fc 100644 --- a/backend/scripts/format.sh +++ b/backend/scripts/format.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -ruff format --check +ruff format --preview diff --git a/pre-commit.sh b/pre-commit.sh deleted file mode 100644 index 5326da99..00000000 --- a/pre-commit.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -prek run --all-files --verbose --show-diff-on-failure