Skip to content

Commit 97f778c

Browse files
authored
Add CLI support for plugin install (#698)
* Add CLI support for plugin install * Fix some usage errors * Update prompt information
1 parent 88695ac commit 97f778c

File tree

5 files changed

+154
-95
lines changed

5 files changed

+154
-95
lines changed

backend/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77

88

99
def get_version() -> str | None:
10-
console.print(f'\n[cyan]{__version__}[/]')
10+
console.print(f'[cyan]{__version__}[/]')

backend/app/admin/api/v1/sys/plugin.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ async def install_plugin(
4343
file: Annotated[UploadFile | None, File()] = None,
4444
repo_url: Annotated[str | None, Query(description='插件 git 仓库地址')] = None,
4545
) -> ResponseModel:
46-
await plugin_service.install(type=type, file=file, repo_url=repo_url)
46+
plugin_name = await plugin_service.install(type=type, file=file, repo_url=repo_url)
4747
return response_base.success(
48-
res=CustomResponse(code=200, msg='插件安装成功,请根据插件说明(README.md)进行相关配置并重启服务')
48+
res=CustomResponse(
49+
code=200, msg=f'插件 {plugin_name} 安装成功,请根据插件说明(README.md)进行相关配置并重启服务'
50+
)
4951
)
5052

5153

@@ -61,7 +63,7 @@ async def install_plugin(
6163
async def uninstall_plugin(plugin: Annotated[str, Path(description='插件名称')]) -> ResponseModel:
6264
await plugin_service.uninstall(plugin=plugin)
6365
return response_base.success(
64-
res=CustomResponse(code=200, msg='插件卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
66+
res=CustomResponse(code=200, msg=f'插件 {plugin} 卸载成功,请根据插件说明(README.md)移除相关配置并重启服务')
6567
)
6668

6769

backend/app/admin/service/plugin_service.py

Lines changed: 5 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,20 @@
33
import io
44
import json
55
import os
6-
import re
76
import shutil
87
import zipfile
98

109
from typing import Any
1110

12-
from dulwich import porcelain
1311
from fastapi import UploadFile
1412

1513
from backend.common.enums import PluginType, StatusType
1614
from backend.common.exception import errors
17-
from backend.common.log import log
1815
from backend.core.conf import settings
1916
from backend.core.path_conf import PLUGIN_DIR
2017
from backend.database.redis import redis_client
21-
from backend.plugin.tools import install_requirements_async, uninstall_requirements_async
22-
from backend.utils.re_verify import is_git_url
18+
from backend.plugin.tools import uninstall_requirements_async
19+
from backend.utils.file_ops import install_git_plugin, install_zip_plugin
2320
from backend.utils.timezone import timezone
2421

2522

@@ -46,76 +43,7 @@ async def changed() -> str | None:
4643
return await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:changed')
4744

4845
@staticmethod
49-
async def install_zip(*, file: UploadFile) -> None:
50-
"""
51-
通过 zip 压缩包安装插件
52-
53-
:param file: 插件 zip 压缩包
54-
:return:
55-
"""
56-
contents = await file.read()
57-
file_bytes = io.BytesIO(contents)
58-
if not zipfile.is_zipfile(file_bytes):
59-
raise errors.RequestError(msg='插件压缩包格式非法')
60-
with zipfile.ZipFile(file_bytes) as zf:
61-
# 校验压缩包
62-
plugin_namelist = zf.namelist()
63-
zip_plugin_dir = plugin_namelist[0].split('/')[0]
64-
if not plugin_namelist:
65-
raise errors.RequestError(msg='插件压缩包内容非法')
66-
if (
67-
len(plugin_namelist) <= 3
68-
or f'{zip_plugin_dir}/plugin.toml' not in plugin_namelist
69-
or f'{zip_plugin_dir}/README.md' not in plugin_namelist
70-
):
71-
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
72-
73-
# 插件是否可安装
74-
plugin_name = re.match(r'^([a-zA-Z0-9_]+)', file.filename.split('.')[0].strip()).group()
75-
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
76-
if os.path.exists(full_plugin_path):
77-
raise errors.ConflictError(msg='此插件已安装')
78-
else:
79-
os.makedirs(full_plugin_path, exist_ok=True)
80-
81-
# 解压(安装)
82-
members = []
83-
for member in zf.infolist():
84-
if member.filename.startswith(zip_plugin_dir):
85-
new_filename = member.filename.replace(zip_plugin_dir, '')
86-
if new_filename:
87-
member.filename = new_filename
88-
members.append(member)
89-
zf.extractall(full_plugin_path, members)
90-
91-
await install_requirements_async(zip_plugin_dir)
92-
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
93-
94-
@staticmethod
95-
async def install_git(*, repo_url: str):
96-
"""
97-
通过 git 安装插件
98-
99-
:param repo_url: git 存储库的 URL
100-
:return:
101-
"""
102-
match = is_git_url(repo_url)
103-
if not match:
104-
raise errors.RequestError(msg='Git 仓库地址格式非法')
105-
repo_name = match.group('repo')
106-
plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1)
107-
if repo_name in plugins:
108-
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
109-
try:
110-
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
111-
except Exception as e:
112-
log.error(f'插件安装失败: {e}')
113-
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
114-
else:
115-
await install_requirements_async(repo_name)
116-
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
117-
118-
async def install(self, *, type: PluginType, file: UploadFile | None = None, repo_url: str | None = None):
46+
async def install(*, type: PluginType, file: UploadFile | None = None, repo_url: str | None = None) -> str:
11947
"""
12048
安装插件
12149
@@ -127,11 +55,11 @@ async def install(self, *, type: PluginType, file: UploadFile | None = None, rep
12755
if type == PluginType.zip:
12856
if not file:
12957
raise errors.RequestError(msg='ZIP 压缩包不能为空')
130-
await self.install_zip(file=file)
58+
return await install_zip_plugin(file)
13159
elif type == PluginType.git:
13260
if not repo_url:
13361
raise errors.RequestError(msg='Git 仓库地址不能为空')
134-
await self.install_git(repo_url=repo_url)
62+
return await install_git_plugin(repo_url)
13563

13664
@staticmethod
13765
async def uninstall(*, plugin: str):

backend/cli.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from dataclasses import dataclass
44
from typing import Annotated
55

6+
import cappa
67
import uvicorn
78

8-
from cappa import Arg, Subcommands, invoke
99
from rich.panel import Panel
1010
from rich.progress import (
1111
Progress,
@@ -16,8 +16,11 @@
1616
from rich.text import Text
1717

1818
from backend import console, get_version
19+
from backend.common.exception.errors import BaseExceptionMixin
1920
from backend.core.conf import settings
2021
from backend.plugin.tools import get_plugins, install_requirements
22+
from backend.utils._await import run_await
23+
from backend.utils.file_ops import install_git_plugin, install_zip_plugin
2124

2225

2326
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:
5356
)
5457

5558
console.print(Panel(panel_content, title='fba 服务信息', border_style='purple', padding=(1, 2)))
56-
uvicorn.run(app='backend.main:app', host=host, port=port, reload=reload, workers=workers)
59+
uvicorn.run(app='backend.main:app', host=host, port=port, reload=not reload, workers=workers)
5760

5861

62+
def install_plugin(path: str, repo_url: str) -> None:
63+
if not path and not repo_url:
64+
raise cappa.Exit('path 或 repo_url 必须指定其中一项', code=1)
65+
if path and repo_url:
66+
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
67+
68+
plugin_name = None
69+
console.print(Text('开始安装插件...', style='bold cyan'))
70+
71+
try:
72+
if path:
73+
plugin_name = run_await(install_zip_plugin)(file=path)
74+
if repo_url:
75+
plugin_name = run_await(install_git_plugin)(repo_url=repo_url)
76+
except Exception as e:
77+
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionMixin) else str(e), code=1)
78+
79+
console.print(Text(f'插件 {plugin_name} 安装成功', style='bold cyan'))
80+
81+
82+
@cappa.command(help='运行服务')
5983
@dataclass
6084
class Run:
6185
host: Annotated[
6286
str,
63-
Arg(
87+
cappa.Arg(
6488
long=True,
6589
default='127.0.0.1',
6690
help='提供服务的主机 IP 地址,对于本地开发,请使用 `127.0.0.1`。'
@@ -69,33 +93,50 @@ class Run:
6993
]
7094
port: Annotated[
7195
int,
72-
Arg(long=True, default=8000, help='提供服务的主机端口号'),
96+
cappa.Arg(long=True, default=8000, help='提供服务的主机端口号'),
7397
]
74-
reload: Annotated[
98+
no_reload: Annotated[
7599
bool,
76-
Arg(long=True, default=True, help='启用在(代码)文件更改时自动重新加载服务器'),
100+
cappa.Arg(long=True, default=False, help='禁用在(代码)文件更改时自动重新加载服务器'),
77101
]
78102
workers: Annotated[
79103
int | None,
80-
Arg(long=True, default=None, help='使用多个工作进程。与 `--reload` 标志互斥'),
104+
cappa.Arg(long=True, default=None, help='使用多个工作进程。与 `--reload` 标志互斥'),
105+
]
106+
107+
def __call__(self):
108+
run(host=self.host, port=self.port, reload=self.no_reload, workers=self.workers)
109+
110+
111+
@cappa.command(help='新增插件')
112+
@dataclass
113+
class Add:
114+
path: Annotated[
115+
str | None,
116+
cappa.Arg(long=True, help='ZIP 插件的本地完整路径'),
117+
]
118+
repo_url: Annotated[
119+
str | None,
120+
cappa.Arg(long=True, help='Git 插件的仓库地址'),
81121
]
82122

83123
def __call__(self):
84-
run(host=self.host, port=self.port, reload=self.reload, workers=self.workers)
124+
install_plugin(path=self.path, repo_url=self.repo_url)
85125

86126

87127
@dataclass
88128
class FbaCli:
89129
version: Annotated[
90130
bool,
91-
Arg(short='-V', long=True, default=False, help='打印 fba 当前版本号'),
131+
cappa.Arg(short='-V', long=True, default=False, help='打印当前版本号'),
92132
]
93-
subcmd: Subcommands[Run | None] = None
133+
subcmd: cappa.Subcommands[Run | Add | None] = None
94134

95135
def __call__(self):
96136
if self.version:
97137
get_version()
98138

99139

100140
def main() -> None:
101-
invoke(FbaCli)
141+
output = cappa.Output(error_format='[red]Error[/]: {message}\n\n更多信息,尝试 "[cyan]--help[/]"')
142+
cappa.invoke(FbaCli, output=output)

backend/utils/file_ops.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
#!/usr/bin/env python3
22
# -*- coding: utf-8 -*-
3+
import io
34
import os
5+
import re
6+
import zipfile
47

58
import aiofiles
69

10+
from dulwich import porcelain
711
from fastapi import UploadFile
812

913
from backend.common.enums import FileType
1014
from backend.common.exception import errors
1115
from backend.common.log import log
1216
from backend.core.conf import settings
13-
from backend.core.path_conf import UPLOAD_DIR
17+
from backend.core.path_conf import PLUGIN_DIR, UPLOAD_DIR
18+
from backend.database.redis import redis_client
19+
from backend.plugin.tools import install_requirements_async
20+
from backend.utils.re_verify import is_git_url
1421
from backend.utils.timezone import timezone
1522

1623

@@ -70,6 +77,87 @@ async def upload_file(file: UploadFile) -> str:
7077
except Exception as e:
7178
log.error(f'上传文件 {filename} 失败:{str(e)}')
7279
raise errors.RequestError(msg='上传文件失败')
73-
finally:
74-
await file.close()
80+
await file.close()
7581
return filename
82+
83+
84+
async def install_zip_plugin(file: UploadFile | str) -> str:
85+
"""
86+
安装 ZIP 插件
87+
88+
:param file: FastAPI 上传文件对象或文件完整路径
89+
:return:
90+
"""
91+
if isinstance(file, str):
92+
async with aiofiles.open(file, mode='rb') as fb:
93+
contents = await fb.read()
94+
else:
95+
contents = await file.read()
96+
file_bytes = io.BytesIO(contents)
97+
if not zipfile.is_zipfile(file_bytes):
98+
raise errors.RequestError(msg='插件压缩包格式非法')
99+
with zipfile.ZipFile(file_bytes) as zf:
100+
# 校验压缩包
101+
plugin_namelist = zf.namelist()
102+
plugin_dir_name = plugin_namelist[0].split('/')[0]
103+
if not plugin_namelist:
104+
raise errors.RequestError(msg='插件压缩包内容非法')
105+
if (
106+
len(plugin_namelist) <= 3
107+
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
108+
or f'{plugin_dir_name}/README.md' not in plugin_namelist
109+
):
110+
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
111+
112+
# 插件是否可安装
113+
plugin_name = re.match(
114+
r'^([a-zA-Z0-9_]+)',
115+
file.split(os.sep)[-1].split('.')[0].strip()
116+
if isinstance(file, str)
117+
else file.filename.split('.')[0].strip(),
118+
).group()
119+
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
120+
if os.path.exists(full_plugin_path):
121+
raise errors.ConflictError(msg='此插件已安装')
122+
else:
123+
os.makedirs(full_plugin_path, exist_ok=True)
124+
125+
# 解压(安装)
126+
members = []
127+
for member in zf.infolist():
128+
if member.filename.startswith(plugin_dir_name):
129+
new_filename = member.filename.replace(plugin_dir_name, '')
130+
if new_filename:
131+
member.filename = new_filename
132+
members.append(member)
133+
zf.extractall(full_plugin_path, members)
134+
135+
await install_requirements_async(plugin_dir_name)
136+
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
137+
138+
return plugin_name
139+
140+
141+
async def install_git_plugin(repo_url: str) -> str:
142+
"""
143+
安装 Git 插件
144+
145+
:param repo_url:
146+
:return:
147+
"""
148+
match = is_git_url(repo_url)
149+
if not match:
150+
raise errors.RequestError(msg='Git 仓库地址格式非法')
151+
repo_name = match.group('repo')
152+
if os.path.exists(os.path.join(PLUGIN_DIR, repo_name)):
153+
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
154+
try:
155+
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
156+
except Exception as e:
157+
log.error(f'插件安装失败: {e}')
158+
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
159+
160+
await install_requirements_async(repo_name)
161+
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
162+
163+
return repo_name

0 commit comments

Comments
 (0)