Add remove plugin and formatting code CLI (#1088)

This commit is contained in:
Wu Clan
2026-02-25 21:22:49 +08:00
committed by GitHub
parent 1aa448796b
commit 6432a66732
7 changed files with 151 additions and 59 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
alembic revision --autogenerate
alembic upgrade head

View File

@@ -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)

View File

@@ -1,3 +1,3 @@
#!/usr/bin/env bash
ruff format --check
ruff format --preview

View File

@@ -1,3 +0,0 @@
#!/usr/bin/env bash
prek run --all-files --verbose --show-diff-on-failure