Skip to content

Commit 8ae0531

Browse files
committed
Add CLI support for plugin install
1 parent 88695ac commit 8ae0531

File tree

4 files changed

+126
-89
lines changed

4 files changed

+126
-89
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/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):
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+
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+
await install_git_plugin(repo_url)
13563

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

backend/cli.py

Lines changed: 33 additions & 8 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:
@@ -56,11 +59,12 @@ def run(host: str, port: int, reload: bool, workers: int | None) -> None:
5659
uvicorn.run(app='backend.main:app', host=host, port=port, reload=reload, workers=workers)
5760

5861

62+
@cappa.command(help='运行服务')
5963
@dataclass
6064
class Run:
6165
host: Annotated[
6266
str,
63-
Arg(
67+
cappa.Arg(
6468
long=True,
6569
default='127.0.0.1',
6670
help='提供服务的主机 IP 地址,对于本地开发,请使用 `127.0.0.1`。'
@@ -69,33 +73,54 @@ class Run:
6973
]
7074
port: Annotated[
7175
int,
72-
Arg(long=True, default=8000, help='提供服务的主机端口号'),
76+
cappa.Arg(long=True, default=8000, help='提供服务的主机端口号'),
7377
]
7478
reload: Annotated[
7579
bool,
76-
Arg(long=True, default=True, help='启用在(代码)文件更改时自动重新加载服务器'),
80+
cappa.Arg(long=True, default=True, help='启用在(代码)文件更改时自动重新加载服务器'),
7781
]
7882
workers: Annotated[
7983
int | None,
80-
Arg(long=True, default=None, help='使用多个工作进程。与 `--reload` 标志互斥'),
84+
cappa.Arg(long=True, default=None, help='使用多个工作进程。与 `--reload` 标志互斥'),
8185
]
8286

8387
def __call__(self):
8488
run(host=self.host, port=self.port, reload=self.reload, workers=self.workers)
8589

8690

91+
@cappa.command(help='新增插件')
92+
@dataclass
93+
class Add:
94+
path: Annotated[str | None, cappa.Arg(long=True, help='ZIP 插件的本地完整路径')]
95+
repo_url: Annotated[str | None, cappa.Arg(long=True, help='Git 插件的仓库地址')]
96+
97+
def __call__(self):
98+
if not self.path and not self.repo_url:
99+
raise cappa.Exit('path 或 repo_url 必须指定其中一项', code=1)
100+
if self.path and self.repo_url:
101+
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
102+
try:
103+
if self.path:
104+
run_await(install_zip_plugin)(file=self.path)
105+
if self.repo_url:
106+
run_await(install_git_plugin)(repo_url=self.repo_url)
107+
except Exception as e:
108+
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionMixin) else str(e), code=1)
109+
110+
87111
@dataclass
88112
class FbaCli:
89113
version: Annotated[
90114
bool,
91-
Arg(short='-V', long=True, default=False, help='打印 fba 当前版本号'),
115+
cappa.Arg(short='-V', long=True, default=False, help='打印当前版本号'),
92116
]
93-
subcmd: Subcommands[Run | None] = None
117+
subcmd: cappa.Subcommands[Run | Add | None] = None
94118

95119
def __call__(self):
96120
if self.version:
97121
get_version()
98122

99123

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

backend/utils/file_ops.py

Lines changed: 87 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,83 @@ 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):
85+
"""
86+
安装 ZIP 插件
87+
88+
:param file: FastAPI 上传文件对象或文件完整路径
89+
:return:
90+
"""
91+
if isinstance(file, UploadFile):
92+
contents = await file.read()
93+
else:
94+
async with aiofiles.open(file, mode='rb') as fb:
95+
contents = await fb.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.filename.split('.')[0].strip()
116+
if isinstance(file, UploadFile)
117+
else file.split(os.sep)[-1].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+
139+
async def install_git_plugin(repo_url: str):
140+
"""
141+
安装 Git 插件
142+
143+
:param repo_url:
144+
:return:
145+
"""
146+
match = is_git_url(repo_url)
147+
if not match:
148+
raise errors.RequestError(msg='Git 仓库地址格式非法')
149+
repo_name = match.group('repo')
150+
if os.path.exists(os.path.join(PLUGIN_DIR, repo_name)):
151+
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
152+
try:
153+
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
154+
except Exception as e:
155+
log.error(f'插件安装失败: {e}')
156+
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
157+
158+
await install_requirements_async(repo_name)
159+
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')

0 commit comments

Comments
 (0)