添加仓库目录多获取渠道

This commit is contained in:
AkashiCoin 2024-09-03 15:43:09 +08:00 committed by AkashiCoin
parent 8615eb20d4
commit 7288d5bdba
8 changed files with 1644 additions and 129 deletions

View File

@ -2,6 +2,7 @@ from typing import cast
from pathlib import Path
from collections.abc import Callable
import pytest
from nonebug import App
from respx import MockRouter
from pytest_mock import MockerFixture
@ -14,7 +15,9 @@ from tests.config import BotId, UserId, GroupId, MessageId
from tests.builtin_plugins.plugin_store.utils import init_mocked_api
@pytest.mark.parametrize("package_api", ["jsd", "gh"])
async def test_add_plugin_basic(
package_api: str,
app: App,
mocker: MockerFixture,
mocked_api: MockRouter,
@ -32,6 +35,11 @@ async def test_add_plugin_basic(
new=tmp_path / "zhenxun",
)
if package_api != "jsd":
mocked_api["zhenxun_bot_plugins_metadata"].respond(404)
if package_api != "gh":
mocked_api["zhenxun_bot_plugins_tree"].respond(404)
plugin_id = 1
async with app.test_matcher(_matcher) as ctx:
@ -61,12 +69,13 @@ async def test_add_plugin_basic(
)
assert mocked_api["basic_plugins"].called
assert mocked_api["extra_plugins"].called
assert mocked_api["zhenxun_bot_plugins_metadata"].called
assert mocked_api["search_image_plugin_file_init"].called
assert (mock_base_path / "plugins" / "search_image" / "__init__.py").is_file()
@pytest.mark.parametrize("package_api", ["jsd", "gh"])
async def test_add_plugin_basic_is_not_dir(
package_api: str,
app: App,
mocker: MockerFixture,
mocked_api: MockRouter,
@ -84,6 +93,11 @@ async def test_add_plugin_basic_is_not_dir(
new=tmp_path / "zhenxun",
)
if package_api != "jsd":
mocked_api["zhenxun_bot_plugins_metadata"].respond(404)
if package_api != "gh":
mocked_api["zhenxun_bot_plugins_tree"].respond(404)
plugin_id = 0
async with app.test_matcher(_matcher) as ctx:
@ -113,12 +127,13 @@ async def test_add_plugin_basic_is_not_dir(
)
assert mocked_api["basic_plugins"].called
assert mocked_api["extra_plugins"].called
assert mocked_api["zhenxun_bot_plugins_metadata"].called
assert mocked_api["jitang_plugin_file"].called
assert (mock_base_path / "plugins" / "alapi" / "jitang.py").is_file()
@pytest.mark.parametrize("package_api", ["jsd", "gh"])
async def test_add_plugin_extra(
package_api: str,
app: App,
mocker: MockerFixture,
mocked_api: MockRouter,
@ -136,6 +151,11 @@ async def test_add_plugin_extra(
new=tmp_path / "zhenxun",
)
if package_api != "jsd":
mocked_api["zhenxun_github_sub_metadata"].respond(404)
if package_api != "gh":
mocked_api["zhenxun_github_sub_tree"].respond(404)
plugin_id = 3
async with app.test_matcher(_matcher) as ctx:
@ -165,7 +185,6 @@ async def test_add_plugin_extra(
)
assert mocked_api["basic_plugins"].called
assert mocked_api["extra_plugins"].called
assert mocked_api["github_sub_plugin_metadata"].called
assert mocked_api["github_sub_plugin_file_init"].called
assert (mock_base_path / "plugins" / "github_sub" / "__init__.py").is_file()

View File

@ -17,14 +17,24 @@ def get_content_bytes(file: str) -> bytes:
def init_mocked_api(mocked_api: MockRouter) -> None:
mocked_api.get(
"https://data.jsdelivr.com/v1/packages/gh/xuanerwa/zhenxun_github_sub@main",
name="github_sub_plugin_metadata",
).respond(json=get_response_json("github_sub_plugin_metadata.json"))
mocked_api.get(
"https://data.jsdelivr.com/v1/packages/gh/zhenxun-org/zhenxun_bot_plugins@main",
name="zhenxun_bot_plugins_metadata",
).respond(json=get_response_json("zhenxun_bot_plugins_metadata.json"))
mocked_api.get(
"https://data.jsdelivr.com/v1/packages/gh/xuanerwa/zhenxun_github_sub@main",
name="zhenxun_github_sub_metadata",
).respond(json=get_response_json("zhenxun_github_sub_metadata.json"))
mocked_api.get(
"https://api.github.com/repos/zhenxun-org/zhenxun_bot_plugins/git/trees/main?recursive=1",
name="zhenxun_bot_plugins_tree",
).respond(json=get_response_json("zhenxun_bot_plugins_tree.json"))
mocked_api.get(
"https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/trees/main?recursive=1",
name="zhenxun_github_sub_tree",
).respond(json=get_response_json("zhenxun_github_sub_tree.json"))
mocked_api.head(
"https://raw.githubusercontent.com/zhenxun-org/zhenxun_bot_plugins/main/plugins.json",
name="head_basic_plugins",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"sha": "438298b9e88f9dafa7020e99d7c7b4c98f93aea6",
"url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/trees/438298b9e88f9dafa7020e99d7c7b4c98f93aea6",
"tree": [
{
"path": "LICENSE",
"mode": "100644",
"type": "blob",
"sha": "f288702d2fa16d3cdf0035b15a9fcbc552cd88e7",
"size": 35149,
"url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/blobs/f288702d2fa16d3cdf0035b15a9fcbc552cd88e7"
},
{
"path": "README.md",
"mode": "100644",
"type": "blob",
"sha": "e974cfc9b973d4a041f03e693ea20563a933b7ca",
"size": 955,
"url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/blobs/e974cfc9b973d4a041f03e693ea20563a933b7ca"
},
{
"path": "github_sub",
"mode": "040000",
"type": "tree",
"sha": "0f7d76bcf472e2ab0610fa542b067633d6e3ae7e",
"url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/trees/0f7d76bcf472e2ab0610fa542b067633d6e3ae7e"
},
{
"path": "github_sub/__init__.py",
"mode": "100644",
"type": "blob",
"sha": "7d17fd49fe82fa3897afcef61b2c694ed93a4ba3",
"size": 7551,
"url": "https://api.github.com/repos/xuanerwa/zhenxun_github_sub/git/blobs/7d17fd49fe82fa3897afcef61b2c694ed93a4ba3"
}
],
"truncated": false
}

View File

@ -20,3 +20,8 @@ JSD_PACKAGE_API_FORMAT = (
"https://data.jsdelivr.com/v1/packages/gh/{owner}/{repo}@{branch}"
)
"""jsdelivr包地址格式"""
GIT_API_TREES_FORMAT = (
"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
)
"""git api trees地址格式"""

View File

@ -12,18 +12,13 @@ from zhenxun.utils.image_utils import RowStyle, BuildImage, ImageTemplate
from zhenxun.builtin_plugins.auto_update.config import REQ_TXT_FILE_STRING
from zhenxun.builtin_plugins.plugin_store.models import (
FileInfo,
FileType,
RepoInfo,
JsdPackageInfo,
TreesInfo,
PackageApi,
StorePluginInfo,
)
from .config import (
BASE_PATH,
EXTRA_GITHUB_URL,
DEFAULT_GITHUB_URL,
JSD_PACKAGE_API_FORMAT,
)
from .config import BASE_PATH, EXTRA_GITHUB_URL, DEFAULT_GITHUB_URL
def row_style(column: str, text: str) -> RowStyle:
@ -42,67 +37,6 @@ def row_style(column: str, text: str) -> RowStyle:
return style
def full_files_path(
jsd_package_info: JsdPackageInfo, module_path: str, is_dir: bool = True
) -> list[FileInfo]:
"""
获取文件路径
参数:
jsd_package_info: JsdPackageInfo
module_path: 模块路径
is_dir: 是否为目录
返回:
list[FileInfo]: 文件路径
"""
paths: list[str] = module_path.split(".")
cur_files: list[FileInfo] = jsd_package_info.files
for path in paths:
for cur_file in cur_files:
if (
cur_file.type == FileType.DIR
and cur_file.name == path
and cur_file.files
and (is_dir or path != paths[-1])
):
cur_files = cur_file.files
break
if not is_dir and path == paths[-1] and cur_file.name.split(".")[0] == path:
return cur_files
else:
raise ValueError(f"模块路径 {module_path} 不存在")
return cur_files
def recurrence_files(
files: list[FileInfo], dir_path: str, is_dir: bool = True
) -> list[str]:
"""
递归获取文件路径
参数:
files: 文件列表
dir_path: 目录路径
is_dir: 是否为目录
返回:
list[str]: 文件路径
"""
paths = []
for file in files:
if is_dir and file.type == FileType.DIR and file.files:
paths.extend(
recurrence_files(file.files, f"{dir_path}/{file.name}", is_dir)
)
elif file.type == FileType.FILE:
if dir_path.endswith(file.name):
paths.append(dir_path)
elif is_dir:
paths.append(f"{dir_path}/{file.name}")
return paths
def install_requirement(plugin_path: Path):
requirement_files = ["requirement.txt", "requirements.txt"]
requirement_paths = [plugin_path / file for file in requirement_files]
@ -274,40 +208,30 @@ class ShopManage:
)
return f"插件 {plugin_key} 安装成功! 重启后生效"
@classmethod
async def get_repo_package_info_of_jsd(cls, repo_info: RepoInfo) -> JsdPackageInfo:
"""获取插件包信息
参数:
repo_info: 仓库信息
返回:
JsdPackageInfo: 插件包信息
"""
jsd_package_url: str = JSD_PACKAGE_API_FORMAT.format(
owner=repo_info.owner, repo=repo_info.repo, branch=repo_info.branch
)
res = await AsyncHttpx.get(url=jsd_package_url)
if res.status_code != 200:
raise ValueError(f"下载错误, code: {res.status_code}")
return JsdPackageInfo(**res.json())
@classmethod
async def install_plugin_with_repo(
cls, github_url: str, module_path: str, is_dir: bool, is_external: bool = False
):
package_api: PackageApi
files: list[str]
package_info: FileInfo | TreesInfo
repo_info = RepoInfo.parse_github_url(github_url)
logger.debug(f"成功获取仓库信息: {repo_info}", "插件管理")
jsd_package_info: JsdPackageInfo = await cls.get_repo_package_info_of_jsd(
repo_info=repo_info
for package_api in PackageApi:
try:
package_info = await package_api.value.parse_repo_info(repo_info)
break
except Exception as e:
logger.warning(
f"获取插件文件失败: {e} | API类型: {package_api.value}", "插件管理"
)
continue
else:
raise ValueError("所有API获取插件文件失败请检查网络连接")
files = package_info.get_files(
module_path=module_path.replace(".", "/") + ("" if is_dir else ".py"),
is_dir=is_dir,
)
files = full_files_path(jsd_package_info, module_path, is_dir)
files = recurrence_files(
files,
module_path.replace(".", "/") + ("" if is_dir else ".py"),
is_dir,
)
logger.debug(f"获取插件文件列表: {files}", "插件管理")
download_urls = [
await repo_info.get_download_url_with_path(file) for file in files
]
@ -321,12 +245,8 @@ class ShopManage:
else:
# 安装依赖
plugin_path = base_path / "/".join(module_path.split("."))
req_files = recurrence_files(
jsd_package_info.files, REQ_TXT_FILE_STRING, False
)
req_files.extend(
recurrence_files(jsd_package_info.files, "requirement.txt", False)
)
req_files = package_info.get_files(REQ_TXT_FILE_STRING, False)
req_files.extend(package_info.get_files("requirement.txt", False))
logger.debug(f"获取插件依赖文件列表: {req_files}", "插件管理")
req_download_urls = [
await repo_info.get_download_url_with_path(file) for file in req_files
@ -357,11 +277,11 @@ class ShopManage:
返回:
str: 返回消息
"""
data = await cls.__get_data()
data: dict[str, StorePluginInfo] = await cls.__get_data()
if plugin_id < 0 or plugin_id >= len(data):
return "插件ID不存在..."
plugin_key = list(data.keys())[plugin_id]
plugin_info = data[plugin_key]
plugin_info = data[plugin_key] # type: ignore
path = BASE_PATH
if plugin_info.github_url:
path = BASE_PATH / "plugins"
@ -388,7 +308,7 @@ class ShopManage:
返回:
BuildImage | str: 返回消息
"""
data = await cls.__get_data()
data: dict[str, StorePluginInfo] = await cls.__get_data()
plugin_list = await cls.get_loaded_plugins("module", "version")
suc_plugin = {p[0]: (p[1] or "Unknown") for p in plugin_list}
filtered_data = [
@ -431,7 +351,7 @@ class ShopManage:
返回:
str: 返回消息
"""
data = await cls.__get_data()
data: dict[str, StorePluginInfo] = await cls.__get_data()
if plugin_id < 0 or plugin_id >= len(data):
return "插件ID不存在..."
plugin_key = list(data.keys())[plugin_id]

View File

@ -1,11 +1,18 @@
from enum import Enum
from abc import ABC, abstractmethod
from aiocache import cached
from strenum import StrEnum
from pydantic import BaseModel, validator
from pydantic import BaseModel
from zhenxun.utils.enum import PluginType
from zhenxun.utils.http_utils import AsyncHttpx
from .config import GITHUB_REPO_URL_PATTERN
from .config import (
GIT_API_TREES_FORMAT,
JSD_PACKAGE_API_FORMAT,
GITHUB_REPO_URL_PATTERN,
)
type2name: dict[str, str] = {
"NORMAL": "普通插件",
@ -40,11 +47,7 @@ class RepoInfo(BaseModel):
owner: str
repo: str
branch: str | None
@validator("branch", pre=True, always=True)
def _set_default_branch(cls, v):
return "main" if v is None else v
branch: str = "main"
async def get_download_url_with_path(self, path: str):
url_format = await self.get_fastest_format()
@ -53,7 +56,7 @@ class RepoInfo(BaseModel):
@classmethod
def parse_github_url(cls, github_url: str) -> "RepoInfo":
if matched := GITHUB_REPO_URL_PATTERN.match(github_url):
return RepoInfo(**matched.groupdict())
return RepoInfo(**{k: v for k, v in matched.groupdict().items() if v})
raise ValueError("github地址格式错误")
@classmethod
@ -83,20 +86,168 @@ class FileType(StrEnum):
FILE = "file"
DIR = "directory"
PACKAGE = "gh"
class FileInfo(BaseModel):
class BaseInfo(BaseModel, ABC):
"""基础信息类"""
@classmethod
@abstractmethod
async def parse_repo_info(cls, repo_info: RepoInfo) -> "BaseInfo": ...
@abstractmethod
def get_files(cls, module_path: str, is_dir) -> list[str]: ...
class FileInfo(BaseInfo):
"""文件信息"""
type: FileType
name: str
files: list["FileInfo"] | None
files: list["FileInfo"] = []
def recurrence_files(self, dir_path: str, is_dir: bool = True) -> list[str]:
"""
递归获取文件路径
参数:
files: 文件列表
dir_path: 目录路径
is_dir: 是否为目录
返回:
list[str]: 文件路径
"""
if not is_dir and dir_path.endswith(self.name):
return [dir_path]
if self.files is None:
raise ValueError("文件列表为空")
paths = []
for file in self.files:
if is_dir and file.type == FileType.DIR and file.files:
paths.extend(self.recurrence_files(f"{dir_path}/{file.name}", is_dir))
elif file.type == FileType.FILE:
if is_dir:
paths.append(f"{dir_path}/{file.name}")
elif dir_path.endswith(file.name):
paths.append(dir_path)
return paths
def full_files_path(self, module_path: str, is_dir: bool = True) -> "FileInfo":
"""
获取文件路径
参数:
module_path: 模块路径
is_dir: 是否为目录
返回:
list[FileInfo]: 文件路径
"""
paths: list[str] = module_path.split("/")
if not is_dir:
paths = paths[:-1]
cur_file: FileInfo = self
for path in paths:
for file in cur_file.files:
if file.type == FileType.DIR and file.name == path and file.files:
cur_file = file
break
else:
raise ValueError(f"模块路径 {module_path} 不存在")
return cur_file
@classmethod
async def parse_repo_info(cls, repo_info: RepoInfo) -> "FileInfo":
"""解析仓库信息"""
"""获取插件包信息
参数:
repo_info: 仓库信息
返回:
FileInfo: 插件包信息
"""
jsd_package_url: str = JSD_PACKAGE_API_FORMAT.format(
owner=repo_info.owner, repo=repo_info.repo, branch=repo_info.branch
)
res = await AsyncHttpx.get(url=jsd_package_url)
if res.status_code != 200:
raise ValueError(f"下载错误, code: {res.status_code}")
return FileInfo(**res.json())
def get_files(self, module_path: str, is_dir: bool = True) -> list[str]:
"""获取文件路径"""
file = self.full_files_path(module_path, is_dir)
files = file.recurrence_files(
module_path,
is_dir,
)
return files
class JsdPackageInfo(BaseModel):
"""jsd包信息"""
class TreeType(StrEnum):
"""树类型"""
type: str
name: str
version: str
files: list[FileInfo]
FILE = "blob"
DIR = "tree"
class Tree(BaseModel):
""""""
path: str
mode: str
type: TreeType
sha: str
size: int | None
url: str
class TreesInfo(BaseInfo):
"""树信息"""
sha: str
url: str
tree: list[Tree]
def export_files(self, module_path: str) -> list[str]:
"""导出文件路径"""
return [
file.path
for file in self.tree
if file.type == TreeType.FILE and file.path.startswith(module_path)
]
@classmethod
async def parse_repo_info(cls, repo_info: RepoInfo) -> "TreesInfo":
"""获取仓库树
参数:
repo_info: 仓库信息
返回:
TreesInfo: 仓库树信息
"""
git_tree_url: str = GIT_API_TREES_FORMAT.format(
owner=repo_info.owner, repo=repo_info.repo, branch=repo_info.branch
)
res = await AsyncHttpx.get(url=git_tree_url)
if res.status_code != 200:
raise ValueError(f"下载错误, code: {res.status_code}")
return TreesInfo(**res.json())
def get_files(self, module_path: str, is_dir: bool = True) -> list[str]:
"""获取文件路径"""
return self.export_files(module_path)
class PackageApi(Enum):
"""插件包接口"""
GITHUB = TreesInfo
JSDELIVR = FileInfo