mirror of
https://github.com/fastapi-practices/fastapi_best_architecture.git
synced 2026-03-13 09:31:31 +08:00
Add remove plugin and formatting code CLI (#1088)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
137
backend/cli.py
137
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:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
alembic revision --autogenerate
|
||||
|
||||
alembic upgrade head
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
ruff format --check
|
||||
ruff format --preview
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
prek run --all-files --verbose --show-diff-on-failure
|
||||
Reference in New Issue
Block a user