mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 04:14:38 +08:00
fix(plugin): support mirrors for Release asset downloads (#7224)
This commit is contained in:
73
app/adapters/external/plugin/client.py
vendored
73
app/adapters/external/plugin/client.py
vendored
@@ -5,11 +5,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from concurrent.futures import Future
|
||||
from pathlib import Path
|
||||
@@ -100,6 +102,20 @@ def _format_request_error(error: Exception) -> str:
|
||||
return f"{type(error).__name__}: {detail}" if detail else type(error).__name__
|
||||
|
||||
|
||||
def _is_release_archive_response(response: Any) -> bool:
|
||||
"""
|
||||
确认镜像返回 ZIP,避免将 HTTP 错误或下载提示页当作发布资产。
|
||||
|
||||
:param response: 已读取响应体的镜像响应
|
||||
:return: 响应成功且包含 ZIP 目录结构时返回 True
|
||||
"""
|
||||
return (
|
||||
response is not None
|
||||
and response.status_code == 200
|
||||
and zipfile.is_zipfile(io.BytesIO(response.content))
|
||||
)
|
||||
|
||||
|
||||
def build_local_repo_url(
|
||||
plugin_id: str,
|
||||
*,
|
||||
@@ -1507,17 +1523,28 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> list[tuple[str, str, PluginRequestOptions]]:
|
||||
"""
|
||||
构造同步与异步 GitHub 请求共用的镜像和单一出口顺序。
|
||||
|
||||
:param url: 原始 GitHub 地址,保留给直连或 HTTP 代理出口
|
||||
:param headers: 原始出口请求头,不会复制到 Release 资产镜像
|
||||
:param timeout: 每个出口请求的超时秒数
|
||||
:param is_api: 普通 API 不使用静态资源镜像
|
||||
:param mirror_url: Release 资产的公开下载地址;提供时先匿名访问其镜像
|
||||
:return: 可选镜像与单一原始出口的有序列表
|
||||
"""
|
||||
strategies: list[tuple[str, str, PluginRequestOptions]] = []
|
||||
if not is_api and get_runtime_setting('GITHUB_PROXY'):
|
||||
content_url = mirror_url or (None if is_api else url)
|
||||
if content_url and get_runtime_setting('GITHUB_PROXY'):
|
||||
proxy_url = (
|
||||
f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{url}"
|
||||
f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{content_url}"
|
||||
)
|
||||
# 发布资产镜像只读取公开文件,不能接收 API Token、Cookie 等凭据。
|
||||
mirror_headers = {"Accept": "application/octet-stream"} if mirror_url else headers
|
||||
strategies.append(
|
||||
("镜像站", proxy_url, {"headers": headers, "timeout": timeout})
|
||||
("镜像站", proxy_url, {"headers": mirror_headers, "timeout": timeout})
|
||||
)
|
||||
if get_runtime_setting('PROXY_HOST'):
|
||||
strategies.append(
|
||||
@@ -1541,7 +1568,8 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
def __request_with_fallback(url: str,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False) -> Optional[Response]:
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None) -> Optional[Response]:
|
||||
"""
|
||||
使用自动降级策略,请求资源:可选镜像站后只使用一个出口;
|
||||
显式配置代理时不再追加无效直连。
|
||||
@@ -1549,7 +1577,8 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
:param url: 目标URL
|
||||
:param headers: 请求头信息
|
||||
:param timeout: 请求超时时间
|
||||
:param is_api: 是否为GitHub API请求,API请求不走镜像站
|
||||
:param is_api: 普通 API 跳过镜像;Release 资产可额外提供公开下载地址
|
||||
:param mirror_url: 可选的公开 Release 资产地址,镜像请求不携带原始请求头
|
||||
:return: 请求成功则返回 Response,失败返回 None
|
||||
"""
|
||||
strategies = PluginMarketTransport._build_github_request_strategies(
|
||||
@@ -1557,6 +1586,7 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
is_api=is_api,
|
||||
mirror_url=mirror_url,
|
||||
)
|
||||
|
||||
# 遍历策略并尝试请求
|
||||
@@ -1565,6 +1595,9 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
|
||||
try:
|
||||
res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
|
||||
if mirror_url and strategy_name == "镜像站" and not _is_release_archive_response(res):
|
||||
logger.warning("[GitHub] 镜像未返回有效 Release 压缩包,继续使用原始出口")
|
||||
continue
|
||||
logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}")
|
||||
return res
|
||||
except Exception as e:
|
||||
@@ -1583,12 +1616,15 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Optional[Response]:
|
||||
"""
|
||||
向包来源客户端公开既有同步 GitHub 降级传输。
|
||||
|
||||
:param mirror_url: 可选的公开 Release 资产地址,失败后保留原 API 出口
|
||||
"""
|
||||
return PluginMarketTransport.__request_with_fallback(
|
||||
url, headers=headers, timeout=timeout, is_api=is_api
|
||||
url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url
|
||||
)
|
||||
|
||||
def __get_plugin_meta(
|
||||
@@ -1671,7 +1707,8 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
async def __async_request_with_fallback(url: str,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False) -> Optional[httpx2.Response]:
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None) -> Optional[httpx2.Response]:
|
||||
"""
|
||||
使用自动降级策略,异步请求资源:可选镜像站后只使用一个出口;
|
||||
显式配置代理时不再追加无效直连。
|
||||
@@ -1679,7 +1716,8 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
:param url: 目标URL
|
||||
:param headers: 请求头信息
|
||||
:param timeout: 请求超时时间
|
||||
:param is_api: 是否为GitHub API请求,API请求不走镜像站
|
||||
:param is_api: 普通 API 跳过镜像;Release 资产可额外提供公开下载地址
|
||||
:param mirror_url: 可选的公开 Release 资产地址,镜像请求不携带原始请求头
|
||||
:return: 请求成功则返回 Response,失败返回 None
|
||||
"""
|
||||
strategies = PluginMarketTransport._build_github_request_strategies(
|
||||
@@ -1687,6 +1725,7 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
is_api=is_api,
|
||||
mirror_url=mirror_url,
|
||||
)
|
||||
|
||||
# 遍历策略并尝试请求
|
||||
@@ -1695,6 +1734,9 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
|
||||
try:
|
||||
res = await AsyncRequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
|
||||
if mirror_url and strategy_name == "镜像站" and not _is_release_archive_response(res):
|
||||
logger.warning("[GitHub] 镜像未返回有效 Release 压缩包,继续使用原始出口")
|
||||
continue
|
||||
logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}")
|
||||
return res
|
||||
except Exception as e:
|
||||
@@ -1713,12 +1755,15 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Optional[httpx2.Response]:
|
||||
"""
|
||||
向包来源客户端公开既有异步 GitHub 降级传输。
|
||||
|
||||
:param mirror_url: 可选的公开 Release 资产地址,失败后保留原 API 出口
|
||||
"""
|
||||
return await PluginMarketTransport.__async_request_with_fallback(
|
||||
url, headers=headers, timeout=timeout, is_api=is_api
|
||||
url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url
|
||||
)
|
||||
|
||||
@cached(maxsize=256, ttl=300, skip_none=False, local_only=True)
|
||||
@@ -2332,12 +2377,15 @@ class PluginPackageSourceClient:
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
同步读取 GitHub 元数据或制品。
|
||||
|
||||
:param mirror_url: 可选的公开 Release 资产地址,镜像使用匿名请求
|
||||
"""
|
||||
return self._transport.request_with_fallback(
|
||||
url, headers=headers, timeout=timeout, is_api=is_api
|
||||
url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url
|
||||
)
|
||||
|
||||
async def async_request(
|
||||
@@ -2347,12 +2395,15 @@ class PluginPackageSourceClient:
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
异步读取 GitHub 元数据或制品。
|
||||
|
||||
:param mirror_url: 可选的公开 Release 资产地址,镜像使用匿名请求
|
||||
"""
|
||||
return await self._transport.async_request_with_fallback(
|
||||
url, headers=headers, timeout=timeout, is_api=is_api
|
||||
url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any, Optional, Protocol, Sequence, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiofiles
|
||||
import aioshutil
|
||||
@@ -244,6 +245,7 @@ class PluginPackageSourcePort(Protocol):
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
同步读取 GitHub 元数据或制品。
|
||||
@@ -251,7 +253,8 @@ class PluginPackageSourcePort(Protocol):
|
||||
:param url: GitHub 元数据或制品地址
|
||||
:param headers: 附加请求头;None 使用请求适配器默认值
|
||||
:param timeout: 每次请求超时,单位为秒,默认 60;None 沿用传输实现约定
|
||||
:param is_api: 是否为 API 请求;True 时不尝试静态资源镜像
|
||||
:param is_api: 是否为 API 请求;True 且未提供公开资产地址时跳过镜像
|
||||
:param mirror_url: 可选的公开 Release 资产地址,镜像匿名下载失败后回退原始地址
|
||||
:return: 已读取内容的响应;所有传输策略均失败时返回 None
|
||||
"""
|
||||
|
||||
@@ -262,6 +265,7 @@ class PluginPackageSourcePort(Protocol):
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
异步读取 GitHub 元数据或制品。
|
||||
@@ -269,7 +273,8 @@ class PluginPackageSourcePort(Protocol):
|
||||
:param url: GitHub 元数据或制品地址
|
||||
:param headers: 附加请求头;None 使用请求适配器默认值
|
||||
:param timeout: 每次请求超时,单位为秒,默认 60;None 沿用传输实现约定
|
||||
:param is_api: 是否为 API 请求;True 时不尝试静态资源镜像
|
||||
:param is_api: 是否为 API 请求;True 且未提供公开资产地址时跳过镜像
|
||||
:param mirror_url: 可选的公开 Release 资产地址,镜像匿名下载失败后回退原始地址
|
||||
:return: 已读取内容的响应;所有传输策略均失败时返回 None
|
||||
"""
|
||||
|
||||
@@ -1049,12 +1054,15 @@ class PluginPackageManager:
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
通过外部来源客户端同步读取远端内容。
|
||||
|
||||
:param mirror_url: 可选的公开 Release 资产地址,由来源客户端隔离镜像凭据
|
||||
"""
|
||||
return self._require_source().request(
|
||||
url, headers=headers, timeout=timeout, is_api=is_api
|
||||
url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url
|
||||
)
|
||||
|
||||
async def __async_request_with_fallback(
|
||||
@@ -1063,12 +1071,15 @@ class PluginPackageManager:
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[int] = 60,
|
||||
is_api: bool = False,
|
||||
mirror_url: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
通过外部来源客户端异步读取远端内容。
|
||||
|
||||
:param mirror_url: 可选的公开 Release 资产地址,由来源客户端隔离镜像凭据
|
||||
"""
|
||||
return await self._require_source().async_request(
|
||||
url, headers=headers, timeout=timeout, is_api=is_api
|
||||
url, headers=headers, timeout=timeout, is_api=is_api, mirror_url=mirror_url
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1081,10 +1092,16 @@ class PluginPackageManager:
|
||||
) -> tuple[Optional[PluginReleaseInstallPlan], str]:
|
||||
"""
|
||||
使用纯领域规则选择 Release 或文件列表安装计划。
|
||||
|
||||
文件列表偏好只影响默认安装,显式选择的 Release 仍按指定版本安装。
|
||||
"""
|
||||
return build_plugin_release_install_plan(
|
||||
plugin_id=pid,
|
||||
metadata=meta,
|
||||
metadata=(
|
||||
meta
|
||||
if release_version or not meta.get("release") or get_runtime_setting('PLUGIN_INSTALL_FROM_RELEASE')
|
||||
else {**meta, "release": False}
|
||||
),
|
||||
release_version=release_version,
|
||||
release_items=release_items,
|
||||
current_version=get_app_version(),
|
||||
@@ -1354,6 +1371,9 @@ class PluginPackageManager:
|
||||
# 未声明 release 打包的插件继续使用文件列表方式安装。
|
||||
|
||||
def prepare_filelist(staging_dir: Path) -> tuple[bool, str]:
|
||||
"""
|
||||
将当前索引对应的源码文件准备到暂存目录。
|
||||
"""
|
||||
return self.__prepare_content_via_filelist_sync(
|
||||
pid,
|
||||
selection.user_repo,
|
||||
@@ -2138,15 +2158,22 @@ class PluginPackageManager:
|
||||
通过 GitHub Release 资产文件安装插件。
|
||||
规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件;
|
||||
将其全部解压到 dest_root,未指定时直接解压到插件运行目录。
|
||||
|
||||
:param pid: 插件标识
|
||||
:param user_repo: GitHub 仓库所有者及仓库名
|
||||
:param release_tag: 要安装的发布标签,同时用于确定规范资产名
|
||||
:param dest_root: 本次安装的暂存目录;None 使用插件运行目录
|
||||
:return: 安装是否成功及失败原因;镜像不可用时保留 API 下载
|
||||
"""
|
||||
# 拼接资产文件名
|
||||
asset_name = f"{release_tag.lower()}.zip"
|
||||
timeout = get_runtime_setting('PLUGIN_RELEASE_TIMEOUT')
|
||||
|
||||
release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}"
|
||||
rel_res = self.__request_with_fallback(
|
||||
release_api,
|
||||
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo),
|
||||
timeout=30,
|
||||
timeout=timeout,
|
||||
is_api=True,
|
||||
)
|
||||
if rel_res is None:
|
||||
@@ -2174,7 +2201,13 @@ class PluginPackageManager:
|
||||
# 使用资产的API端点下载,需要设置Accept头为application/octet-stream
|
||||
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy()
|
||||
headers["Accept"] = "application/octet-stream"
|
||||
res = self.__request_with_fallback(download_url, headers=headers, is_api=True)
|
||||
mirror_url = (
|
||||
f"https://github.com/{user_repo}/releases/download/"
|
||||
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
|
||||
)
|
||||
res = self.__request_with_fallback(
|
||||
download_url, headers=headers, timeout=timeout, is_api=True, mirror_url=mirror_url,
|
||||
)
|
||||
if res is None or res.status_code != 200:
|
||||
return False, f"下载资产失败:{res.status_code if res else '连接失败'}"
|
||||
|
||||
@@ -2535,6 +2568,9 @@ class PluginPackageManager:
|
||||
# 未声明 release 打包的插件继续使用文件列表方式安装。
|
||||
|
||||
async def prepare_filelist(staging_dir: Path) -> tuple[bool, str]:
|
||||
"""
|
||||
将当前索引对应的源码文件异步准备到暂存目录。
|
||||
"""
|
||||
return await self.__prepare_content_via_filelist_async(
|
||||
pid,
|
||||
selection.user_repo,
|
||||
@@ -2733,15 +2769,22 @@ class PluginPackageManager:
|
||||
通过 GitHub Release 资产文件安装插件(异步)。
|
||||
规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件;
|
||||
将其全部解压到 dest_root,未指定时直接解压到插件运行目录。
|
||||
|
||||
:param pid: 插件标识
|
||||
:param user_repo: GitHub 仓库所有者及仓库名
|
||||
:param release_tag: 要安装的发布标签,同时用于确定规范资产名
|
||||
:param dest_root: 本次安装的暂存目录;None 使用插件运行目录
|
||||
:return: 安装是否成功及失败原因;镜像不可用时保留 API 下载
|
||||
"""
|
||||
# 拼接资产文件名
|
||||
asset_name = f"{release_tag.lower()}.zip"
|
||||
timeout = get_runtime_setting('PLUGIN_RELEASE_TIMEOUT')
|
||||
|
||||
release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}"
|
||||
rel_res = await self.__async_request_with_fallback(
|
||||
release_api,
|
||||
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo),
|
||||
timeout=30,
|
||||
timeout=timeout,
|
||||
is_api=True,
|
||||
)
|
||||
if rel_res is None:
|
||||
@@ -2769,9 +2812,13 @@ class PluginPackageManager:
|
||||
# 使用资产的API端点下载,需要设置Accept头为application/octet-stream
|
||||
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy()
|
||||
headers["Accept"] = "application/octet-stream"
|
||||
res = await self.__async_request_with_fallback(download_url,
|
||||
headers=headers,
|
||||
is_api=True)
|
||||
mirror_url = (
|
||||
f"https://github.com/{user_repo}/releases/download/"
|
||||
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
|
||||
)
|
||||
res = await self.__async_request_with_fallback(
|
||||
download_url, headers=headers, timeout=timeout, is_api=True, mirror_url=mirror_url,
|
||||
)
|
||||
if res is None or res.status_code != 200:
|
||||
return False, f"下载资产失败:{res.status_code if res else '连接失败'}"
|
||||
|
||||
|
||||
@@ -1213,6 +1213,12 @@
|
||||
"source_file": "app/runtime/config.py",
|
||||
"source_line": 613
|
||||
},
|
||||
"PLUGIN_INSTALL_FROM_RELEASE": {
|
||||
"description": "插件默认优先安装 Release 资产;false 直接安装当前索引文件,显式选择版本不受影响",
|
||||
"group": "plugins",
|
||||
"source_file": "app/runtime/config.py",
|
||||
"source_line": 633
|
||||
},
|
||||
"PLUGIN_LOCAL_REPO_PATHS": {
|
||||
"description": "本地插件仓库目录,多个地址使用,分隔",
|
||||
"group": "plugins",
|
||||
@@ -1225,6 +1231,12 @@
|
||||
"source_file": "app/runtime/config.py",
|
||||
"source_line": 607
|
||||
},
|
||||
"PLUGIN_RELEASE_TIMEOUT": {
|
||||
"description": "插件 Release 元数据和资产各出口的请求超时秒数,默认 30,范围 1 至 300",
|
||||
"group": "plugins",
|
||||
"source_file": "app/runtime/config.py",
|
||||
"source_line": 637
|
||||
},
|
||||
"PLUGIN_STATISTIC_SHARE": {
|
||||
"description": "插件安装数据共享",
|
||||
"group": "plugins",
|
||||
|
||||
@@ -630,6 +630,14 @@ class ConfigModel(BaseModel):
|
||||
PLUGIN_STATISTIC_SHARE: bool = Field(default=True, description='插件安装数据共享')
|
||||
USAGE_STATISTIC_SHARE: bool = Field(default=True, description='安装版本统计上报')
|
||||
PLUGIN_AUTO_RELOAD: bool = Field(default=False, description='是否开启插件热加载')
|
||||
PLUGIN_INSTALL_FROM_RELEASE: bool = Field(
|
||||
default=True,
|
||||
description='插件默认优先安装 Release 资产;false 直接安装当前索引文件,显式选择版本不受影响',
|
||||
)
|
||||
PLUGIN_RELEASE_TIMEOUT: int = Field(
|
||||
default=30, ge=1, le=300,
|
||||
description='插件 Release 元数据和资产各出口的请求超时秒数,默认 30,范围 1 至 300',
|
||||
)
|
||||
DEPRECATION_ENABLED: Optional[str] = Field(default=None, description='临时放行的废弃标识,多个用,分隔;仅对已进入停用阶段的接口有效,用于观察真实依赖方')
|
||||
PLUGIN_LOCAL_REPO_PATHS: Optional[str] = Field(default=None, description='本地插件仓库目录,多个地址使用,分隔')
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ def _patch_release_install_settings(monkeypatch, tmp_path: Path) -> None:
|
||||
values = SimpleNamespace(
|
||||
ROOT_PATH=tmp_path,
|
||||
REPO_GITHUB_HEADERS=lambda repo=None: {},
|
||||
PLUGIN_RELEASE_TIMEOUT=30,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.get_runtime_setting",
|
||||
@@ -218,6 +219,9 @@ def _patch_release_install_settings(monkeypatch, tmp_path: Path) -> None:
|
||||
safe_root = tmp_path.resolve()
|
||||
|
||||
def guarded_mkdir(path: Path, *args, **kwargs):
|
||||
"""
|
||||
拒绝在测试临时目录以外创建插件文件夹。
|
||||
"""
|
||||
try:
|
||||
path.resolve().relative_to(safe_root)
|
||||
except ValueError as exc:
|
||||
|
||||
235
tests/test_plugin_release_transport.py
Normal file
235
tests/test_plugin_release_transport.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
验证插件 Release 下载出口、凭据隔离和安装方式配置。
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.adapters.external.plugin import client
|
||||
from app.adapters.external.plugin.client import PluginPackageSourceClient
|
||||
from app.adapters.system.plugin import package
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
from app.runtime.config import ConfigModel
|
||||
|
||||
|
||||
def _archive() -> bytes:
|
||||
"""
|
||||
生成可安装的插件 ZIP,不访问网络或运行目录。
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("__init__.py", "plugin_payload = True\n")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize("mirror_result", ["success", "timeout", "http_error", "html"])
|
||||
@pytest.mark.parametrize("use_proxy", [False, True])
|
||||
@pytest.mark.parametrize("authenticated", [False, True])
|
||||
async def test_release_install_uses_anonymous_mirror_then_authenticated_api(
|
||||
monkeypatch, tmp_path, asynchronous, mirror_result, use_proxy, authenticated,
|
||||
):
|
||||
"""
|
||||
镜像只收到公开下载地址与无凭据请求头,失败后由原 API 出口接管。
|
||||
"""
|
||||
headers = {"Authorization": "Bearer test-token", "Cookie": "test-cookie"} if authenticated else {}
|
||||
proxy = {"https": "http://proxy.example:7890"}
|
||||
settings = {
|
||||
"GITHUB_PROXY": "https://mirror.example",
|
||||
"PROXY_HOST": "http://proxy.example:7890" if use_proxy else None,
|
||||
"PROXY": proxy if use_proxy else None,
|
||||
"PLUGIN_RELEASE_TIMEOUT": 7,
|
||||
"REPO_GITHUB_HEADERS": lambda **_kwargs: headers.copy(),
|
||||
}
|
||||
for module in (client, package):
|
||||
monkeypatch.setattr(
|
||||
module, "get_runtime_setting", lambda key, default=None: settings.get(key, default),
|
||||
)
|
||||
requests = []
|
||||
archive = _archive()
|
||||
|
||||
def send(options, *, url, raise_exception):
|
||||
"""
|
||||
回放元数据、镜像故障及 API 二进制下载,并记录真实出口参数。
|
||||
"""
|
||||
assert raise_exception is True
|
||||
requests.append((url, options))
|
||||
if "/releases/tags/" in url:
|
||||
return SimpleNamespace(
|
||||
status_code=200,
|
||||
json=lambda: {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 123}]},
|
||||
)
|
||||
if url.startswith("https://mirror.example/"):
|
||||
if mirror_result == "timeout":
|
||||
raise TimeoutError("mirror unavailable")
|
||||
return SimpleNamespace(
|
||||
status_code=503 if mirror_result == "http_error" else 200,
|
||||
content=b"<html>download unavailable</html>" if mirror_result == "html" else archive,
|
||||
)
|
||||
return SimpleNamespace(status_code=200, content=archive)
|
||||
|
||||
def request_factory(**options):
|
||||
"""
|
||||
为同步或异步适配器提供相同的离线响应序列。
|
||||
"""
|
||||
request_mock = AsyncMock if asynchronous else Mock
|
||||
return SimpleNamespace(
|
||||
get_res=request_mock(side_effect=lambda **kwargs: send(options, **kwargs)),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(client, "AsyncRequestUtils" if asynchronous else "RequestUtils", request_factory)
|
||||
manager = PluginPackageManager(source=PluginPackageSourceClient(), plugin_root=tmp_path)
|
||||
if asynchronous:
|
||||
result = await manager.async_install_from_release("DemoPlugin", "owner/repo", "DemoPlugin_v1.2.3")
|
||||
else:
|
||||
result = manager.install_from_release("DemoPlugin", "owner/repo", "DemoPlugin_v1.2.3")
|
||||
|
||||
assert result == (True, "")
|
||||
assert (tmp_path / "demoplugin" / "__init__.py").read_text() == "plugin_payload = True\n"
|
||||
assert requests[0] == (
|
||||
"https://api.github.com/repos/owner/repo/releases/tags/DemoPlugin_v1.2.3",
|
||||
{"headers": headers, "timeout": 7, **({"proxies": proxy} if use_proxy else {})},
|
||||
)
|
||||
assert requests[1] == (
|
||||
"https://mirror.example/https://github.com/owner/repo/releases/download/"
|
||||
"DemoPlugin_v1.2.3/demoplugin_v1.2.3.zip",
|
||||
{"headers": {"Accept": "application/octet-stream"}, "timeout": 7},
|
||||
)
|
||||
if mirror_result == "success":
|
||||
assert len(requests) == 2
|
||||
else:
|
||||
assert requests[2:] == [(
|
||||
"https://api.github.com/repos/owner/repo/releases/assets/123",
|
||||
{
|
||||
"headers": {**headers, "Accept": "application/octet-stream"},
|
||||
"timeout": 7,
|
||||
**({"proxies": proxy} if use_proxy else {}),
|
||||
},
|
||||
)]
|
||||
assert headers == ({"Authorization": "Bearer test-token", "Cookie": "test-cookie"} if authenticated else {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_proxy", [False, True])
|
||||
def test_unconfigured_mirror_preserves_authenticated_asset_api(monkeypatch, use_proxy):
|
||||
"""
|
||||
未配置镜像时,带令牌的资产 API 只使用原有代理或直连出口。
|
||||
"""
|
||||
proxy = {"https": "http://proxy.example:7890"}
|
||||
settings = {"GITHUB_PROXY": "", "PROXY_HOST": "proxy" if use_proxy else None, "PROXY": proxy}
|
||||
monkeypatch.setattr(client, "get_runtime_setting", lambda key: settings[key])
|
||||
headers = {"authorization": "Bearer private-token", "Accept": "application/octet-stream"}
|
||||
api_url = "https://api.github.com/repos/owner/private/releases/assets/123"
|
||||
strategies = client.PluginMarketTransport._build_github_request_strategies(
|
||||
api_url, headers=headers, timeout=7, is_api=True,
|
||||
mirror_url="https://github.com/owner/private/releases/download/v1/plugin.zip",
|
||||
)
|
||||
assert strategies == [(
|
||||
"代理" if use_proxy else "直连", api_url,
|
||||
{"headers": headers, "timeout": 7, **({"proxies": proxy} if use_proxy else {})},
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
async def test_filelist_preference_skips_release_requests(monkeypatch, tmp_path, asynchronous):
|
||||
"""
|
||||
关闭默认 Release 安装后,同步与异步入口都直接准备索引文件。
|
||||
"""
|
||||
settings = {"PLUGIN_INSTALL_FROM_RELEASE": False, "VERSION_FLAG": "v3"}
|
||||
monkeypatch.setattr(package, "get_runtime_setting", lambda key: settings[key])
|
||||
source = Mock()
|
||||
source.is_local_repo_url.return_value = False
|
||||
source.get_repo_info.return_value = ("owner", "repo")
|
||||
source.get_plugin_package_version.return_value = "v3"
|
||||
metadata = {"DemoPlugin": {"release": True, "version": "1.2.3"}}
|
||||
source.get_plugins.return_value = metadata
|
||||
source.async_get_plugin_package_version = AsyncMock(return_value="v3")
|
||||
source.async_get_plugins = AsyncMock(return_value=metadata)
|
||||
manager = PluginPackageManager(source=source, plugin_root=tmp_path)
|
||||
prepare = AsyncMock(return_value=(True, "")) if asynchronous else Mock(return_value=(True, ""))
|
||||
monkeypatch.setattr(
|
||||
manager, "_PluginPackageManager__prepare_content_via_filelist_async" if asynchronous
|
||||
else "_PluginPackageManager__prepare_content_via_filelist_sync", prepare,
|
||||
)
|
||||
|
||||
async def async_flow(_pid, _force, prepare_content, *_args):
|
||||
"""
|
||||
执行异步内容准备,保留本用例关注的安装分支选择。
|
||||
"""
|
||||
return await prepare_content(tmp_path)
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager, "_PluginPackageManager__install_flow_async" if asynchronous
|
||||
else "_PluginPackageManager__install_flow_sync",
|
||||
async_flow if asynchronous else lambda _pid, _force, prepare_content, *_args: prepare_content(tmp_path),
|
||||
)
|
||||
if asynchronous:
|
||||
result = await manager.async_install_raw("DemoPlugin", "https://github.com/owner/repo")
|
||||
prepare.assert_awaited_once()
|
||||
else:
|
||||
result = manager.install_raw("DemoPlugin", "https://github.com/owner/repo")
|
||||
prepare.assert_called_once()
|
||||
assert result == (True, "")
|
||||
source.request.assert_not_called()
|
||||
source.async_request.assert_not_called()
|
||||
source.get_plugin_release_versions.assert_not_called()
|
||||
source.async_get_plugin_release_versions.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefer_release", [False, True])
|
||||
@pytest.mark.parametrize("release_version", [None, "1.2.0"])
|
||||
def test_filelist_preference_preserves_explicit_release(monkeypatch, prefer_release, release_version):
|
||||
"""
|
||||
文件列表偏好跳过默认 Release,但用户指定版本时仍保持历史版本准入。
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
package, "get_runtime_setting", lambda key: prefer_release if key == "PLUGIN_INSTALL_FROM_RELEASE" else None,
|
||||
)
|
||||
selection, message = PluginPackageManager._select_remote_install(
|
||||
pid="DemoPlugin", user_repo="owner/repo", package_version="v3",
|
||||
meta={"release": True, "version": "1.2.3"},
|
||||
release_version=release_version, release_items=[{"version": "1.2.0"}],
|
||||
)
|
||||
assert message == ""
|
||||
assert selection is not None
|
||||
expected = f"DemoPlugin_v{release_version or '1.2.3'}" if prefer_release or release_version else None
|
||||
assert selection.release_tag == expected
|
||||
assert selection.fallback_to_filelist is (prefer_release and release_version is None)
|
||||
|
||||
|
||||
def test_release_configuration_defaults_and_bounds():
|
||||
"""
|
||||
默认保持 Release 优先,超时支持字符串环境值并拒绝无效范围。
|
||||
"""
|
||||
defaults = ConfigModel()
|
||||
assert defaults.PLUGIN_INSTALL_FROM_RELEASE is True
|
||||
assert defaults.PLUGIN_RELEASE_TIMEOUT == 30
|
||||
configured = ConfigModel(PLUGIN_INSTALL_FROM_RELEASE="false", PLUGIN_RELEASE_TIMEOUT="7")
|
||||
assert configured.PLUGIN_INSTALL_FROM_RELEASE is False
|
||||
assert configured.PLUGIN_RELEASE_TIMEOUT == 7
|
||||
for value in (0, -1, 301):
|
||||
with pytest.raises(ValidationError):
|
||||
ConfigModel(PLUGIN_RELEASE_TIMEOUT=value)
|
||||
|
||||
|
||||
def test_release_configuration_is_registered_in_system_settings():
|
||||
"""
|
||||
新配置须同时进入系统设置目录,避免 API 在加载配置合同时失败。
|
||||
"""
|
||||
from app.application.settings.contract import CORE_SETTING_SPECS, build_value_schema
|
||||
|
||||
preference = CORE_SETTING_SPECS["PLUGIN_INSTALL_FROM_RELEASE"]
|
||||
timeout = CORE_SETTING_SPECS["PLUGIN_RELEASE_TIMEOUT"]
|
||||
assert preference.group == timeout.group == "plugins"
|
||||
assert preference.examples == (True,)
|
||||
assert timeout.examples == (30,)
|
||||
assert timeout.unit == "seconds"
|
||||
assert build_value_schema(preference)["type"] == "boolean"
|
||||
assert build_value_schema(timeout)["minimum"] == 1
|
||||
assert build_value_schema(timeout)["maximum"] == 300
|
||||
Reference in New Issue
Block a user