mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 13:55:00 +08:00
refactor(plugin): unify adapter decisions
This commit is contained in:
56
app/adapters/external/plugin/client.py
vendored
56
app/adapters/external/plugin/client.py
vendored
@@ -976,11 +976,35 @@ class PluginMarketTransport:
|
||||
content: str,
|
||||
) -> Optional[PluginIndex]:
|
||||
"""统一解释插件索引 HTTP 响应,保留不存在、失败和有效索引三态。"""
|
||||
if status_code == 404:
|
||||
return {}
|
||||
if status_code != 200:
|
||||
try:
|
||||
payload = cls._resolve_plugin_index_result((status_code, content))
|
||||
except RuntimeError:
|
||||
return None
|
||||
return cls.__parse_plugin_index_response(content)
|
||||
return payload if payload is not None else {}
|
||||
|
||||
@classmethod
|
||||
def _resolve_plugin_index_result(
|
||||
cls,
|
||||
response: Optional[tuple[int, str]],
|
||||
) -> Optional[PluginIndex]:
|
||||
"""
|
||||
把传输结果统一映射为索引读取合同。
|
||||
|
||||
:param response: 传输层返回的状态码和已受限响应文本;None 表示连接失败
|
||||
:return: 有效索引,或以 None 表示仓库明确不存在该索引
|
||||
:raises RuntimeError: 连接失败、非成功状态或响应格式无效
|
||||
"""
|
||||
if response is None:
|
||||
raise RuntimeError("插件索引请求失败:连接失败")
|
||||
status_code, content = response
|
||||
if status_code == 404:
|
||||
return None
|
||||
if status_code != 200:
|
||||
raise RuntimeError(f"插件索引请求失败:HTTP {status_code}")
|
||||
payload = cls.__parse_plugin_index_response(content)
|
||||
if payload is None:
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def __build_plugin_release_item(
|
||||
@@ -1120,17 +1144,7 @@ class PluginMarketTransport:
|
||||
package_url,
|
||||
headers=headers,
|
||||
)
|
||||
if response is None:
|
||||
raise RuntimeError("插件索引请求失败:连接失败")
|
||||
status_code, content = response
|
||||
if status_code == 404:
|
||||
return None
|
||||
if status_code != 200:
|
||||
raise RuntimeError(f"插件索引请求失败:HTTP {status_code}")
|
||||
payload = self.__parse_plugin_index_response(content)
|
||||
if payload is None:
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
return self._resolve_plugin_index_result(response)
|
||||
|
||||
def get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[PluginIndex]:
|
||||
@@ -1459,17 +1473,7 @@ class PluginMarketTransport:
|
||||
package_url,
|
||||
headers=headers,
|
||||
)
|
||||
if response is None:
|
||||
raise RuntimeError("插件索引请求失败:连接失败")
|
||||
status_code, content = response
|
||||
if status_code == 404:
|
||||
return None
|
||||
if status_code != 200:
|
||||
raise RuntimeError(f"插件索引请求失败:HTTP {status_code}")
|
||||
payload = self.__parse_plugin_index_response(content)
|
||||
if payload is None:
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
return self._resolve_plugin_index_result(response)
|
||||
|
||||
async def async_get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[PluginIndex]:
|
||||
|
||||
@@ -35,6 +35,14 @@ class _RequirementGroup:
|
||||
specifiers: set[str] = field(default_factory=set) # 待求交集的版本约束
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _DependencyInstallRequest:
|
||||
"""封装一次插件依赖安装所需的清单和本地 wheel 来源。"""
|
||||
|
||||
manifest_paths: list[Path]
|
||||
wheels_dirs: list[Path]
|
||||
|
||||
|
||||
class PluginDependencyPackagePort(Protocol):
|
||||
"""声明依赖聚合器所需的唯一 Python 包安装边界。"""
|
||||
|
||||
@@ -360,21 +368,46 @@ class PluginDependencyInstaller:
|
||||
result.append(wheels_dir)
|
||||
return list(dict.fromkeys(result))
|
||||
|
||||
def install(self, dependencies: list[str]) -> tuple[bool, str]:
|
||||
"""把已安装插件的原始清单交给一次统一包安装。"""
|
||||
def _prepare_install_request(
|
||||
self, dependencies: list[str]
|
||||
) -> tuple[Optional[_DependencyInstallRequest], Optional[tuple[bool, str]]]:
|
||||
"""统一校验依赖安装请求,并在进入包 I/O 前构造规范输入。"""
|
||||
if not dependencies:
|
||||
return False, "没有传入需要安装的依赖项"
|
||||
return None, (False, "没有传入需要安装的依赖项")
|
||||
try:
|
||||
manifest_paths = [manifest.path for manifest in self._plugin_manifests()]
|
||||
if not manifest_paths:
|
||||
return False, "没有找到已安装插件的依赖清单"
|
||||
return self._packages.install_packages_with_fallback(
|
||||
manifest_paths,
|
||||
self._wheels_dirs(),
|
||||
return None, (False, "没有找到已安装插件的依赖清单")
|
||||
return (
|
||||
_DependencyInstallRequest(
|
||||
manifest_paths=manifest_paths,
|
||||
wheels_dirs=self._wheels_dirs(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"安装依赖项时发生错误:{err}")
|
||||
return False, f"安装依赖项时发生错误:{err}"
|
||||
except Exception as error: # noqa: BLE001 - 统一映射为公开安装结果
|
||||
return None, self._dependency_install_failure(error)
|
||||
|
||||
@staticmethod
|
||||
def _dependency_install_failure(error: Exception) -> tuple[bool, str]:
|
||||
"""统一记录同步与异步包安装异常,并保留既有错误文本合同。"""
|
||||
message = f"安装依赖项时发生错误:{error}"
|
||||
logger.error(message)
|
||||
return False, message
|
||||
|
||||
def install(self, dependencies: list[str]) -> tuple[bool, str]:
|
||||
"""把已安装插件的原始清单交给一次统一包安装。"""
|
||||
request, error_result = self._prepare_install_request(dependencies)
|
||||
if error_result is not None:
|
||||
return error_result
|
||||
assert request is not None
|
||||
try:
|
||||
return self._packages.install_packages_with_fallback(
|
||||
request.manifest_paths,
|
||||
request.wheels_dirs,
|
||||
)
|
||||
except Exception as error: # noqa: BLE001 - 统一映射为公开安装结果
|
||||
return self._dependency_install_failure(error)
|
||||
|
||||
async def async_find_missing(self) -> list[str]:
|
||||
"""在线程池中扫描缺失依赖,避免阻塞事件循环。"""
|
||||
@@ -382,16 +415,14 @@ class PluginDependencyInstaller:
|
||||
|
||||
async def async_install(self, dependencies: list[str]) -> tuple[bool, str]:
|
||||
"""异步安装依赖,使用可取消的包安装子进程。"""
|
||||
if not dependencies:
|
||||
return False, "没有传入需要安装的依赖项"
|
||||
request, error_result = self._prepare_install_request(dependencies)
|
||||
if error_result is not None:
|
||||
return error_result
|
||||
assert request is not None
|
||||
try:
|
||||
manifest_paths = [manifest.path for manifest in self._plugin_manifests()]
|
||||
if not manifest_paths:
|
||||
return False, "没有找到已安装插件的依赖清单"
|
||||
return await self._packages.async_install_packages_with_fallback(
|
||||
manifest_paths,
|
||||
self._wheels_dirs(),
|
||||
request.manifest_paths,
|
||||
request.wheels_dirs,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"安装依赖项时发生错误:{err}")
|
||||
return False, f"安装依赖项时发生错误:{err}"
|
||||
except Exception as error: # noqa: BLE001 - 统一映射为公开安装结果
|
||||
return self._dependency_install_failure(error)
|
||||
|
||||
@@ -161,6 +161,16 @@ class PluginPackageCheckpoint:
|
||||
return self.transaction_dir / ".rollback-complete"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RemotePluginInstallSelection:
|
||||
"""冻结远端插件完成准入后的安装方式和索引事实。"""
|
||||
|
||||
user_repo: str
|
||||
package_version: str
|
||||
release_tag: Optional[str]
|
||||
fallback_to_filelist: bool
|
||||
|
||||
|
||||
class PluginPackageManager:
|
||||
"""隔离插件包安装、本地同步、分身改写和文件补偿能力。"""
|
||||
|
||||
@@ -760,6 +770,69 @@ class PluginPackageManager:
|
||||
current_version=get_app_version(),
|
||||
)
|
||||
|
||||
def _resolve_remote_plugin_repository(
|
||||
self,
|
||||
pid: str,
|
||||
repo_url: str,
|
||||
) -> tuple[Optional[str], Optional[tuple[bool, str]]]:
|
||||
"""统一校验远端安装前置条件并返回规范仓库标识。"""
|
||||
if SystemUtils.is_frozen():
|
||||
return None, (False, "可执行文件模式下,只能安装本地插件")
|
||||
if not pid or not repo_url:
|
||||
return None, (False, "参数错误")
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return None, (False, "不支持的插件仓库地址格式")
|
||||
return f"{user}/{repo}", None
|
||||
|
||||
@staticmethod
|
||||
def _accept_remote_package_version(
|
||||
pid: str,
|
||||
package_version: Optional[str],
|
||||
) -> Optional[tuple[bool, str]]:
|
||||
"""统一记录索引代际选择,并映射找不到兼容插件的失败结果。"""
|
||||
if package_version is None:
|
||||
message = f"{pid} 没有找到适用于当前版本的插件"
|
||||
logger.debug(message)
|
||||
return False, message
|
||||
if package_version:
|
||||
logger.debug(
|
||||
f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _select_remote_install(
|
||||
cls,
|
||||
*,
|
||||
pid: str,
|
||||
user_repo: str,
|
||||
package_version: str,
|
||||
meta: dict[str, Any],
|
||||
release_version: Optional[str],
|
||||
release_items: Sequence[dict[str, Any]],
|
||||
) -> tuple[Optional[_RemotePluginInstallSelection], str]:
|
||||
"""把元数据和 Release 列表统一映射为可执行的远端安装选择。"""
|
||||
plan, message = cls._build_remote_plugin_install_plan(
|
||||
pid=pid,
|
||||
meta=meta,
|
||||
release_version=release_version,
|
||||
release_items=release_items,
|
||||
)
|
||||
if plan is None:
|
||||
return None, message
|
||||
return (
|
||||
_RemotePluginInstallSelection(
|
||||
user_repo=user_repo,
|
||||
package_version=package_version,
|
||||
release_tag=plan.release_tag,
|
||||
fallback_to_filelist=plan.fallback_to_filelist,
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
def install_packages_with_fallback(
|
||||
self,
|
||||
dependency_files: Path | Sequence[Path],
|
||||
@@ -851,58 +924,47 @@ class PluginPackageManager:
|
||||
before_dependency_install=before_dependency_install,
|
||||
)
|
||||
|
||||
if SystemUtils.is_frozen():
|
||||
return False, "可执行文件模式下,只能安装本地插件"
|
||||
user_repo, error_result = self._resolve_remote_plugin_repository(
|
||||
pid, repo_url
|
||||
)
|
||||
if error_result is not None:
|
||||
return error_result
|
||||
assert user_repo is not None
|
||||
|
||||
# 验证参数
|
||||
if not pid or not repo_url:
|
||||
return False, "参数错误"
|
||||
|
||||
# 从 GitHub 的 repo_url 获取用户和项目名
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return False, "不支持的插件仓库地址格式"
|
||||
|
||||
user_repo = f"{user}/{repo}"
|
||||
|
||||
if not package_version:
|
||||
package_version = get_runtime_setting('VERSION_FLAG')
|
||||
|
||||
# 1. 优先检查指定版本的插件
|
||||
package_version = self.get_plugin_package_version(pid, repo_url, package_version)
|
||||
# 如果 package_version 为None,说明没有找到匹配的插件
|
||||
if package_version is None:
|
||||
msg = f"{pid} 没有找到适用于当前版本的插件"
|
||||
logger.debug(msg)
|
||||
return False, msg
|
||||
# package_version 为空,表示从 package.json 中找到插件
|
||||
elif package_version == "":
|
||||
logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件")
|
||||
else:
|
||||
logger.debug(f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件")
|
||||
preferred_version = package_version or get_runtime_setting('VERSION_FLAG')
|
||||
selected_version = self.get_plugin_package_version(
|
||||
pid, repo_url, preferred_version
|
||||
)
|
||||
if error_result := self._accept_remote_package_version(
|
||||
pid, selected_version
|
||||
):
|
||||
return error_result
|
||||
assert selected_version is not None
|
||||
|
||||
# 2. 决定安装方式(release 或文件列表)并执行统一安装流程。
|
||||
meta = self.__get_plugin_meta(pid, repo_url, package_version)
|
||||
meta = self.__get_plugin_meta(pid, repo_url, selected_version)
|
||||
release_items = (
|
||||
self.get_plugin_release_versions(pid, repo_url)
|
||||
if release_version
|
||||
else []
|
||||
)
|
||||
plan, message = self._build_remote_plugin_install_plan(
|
||||
selection, message = self._select_remote_install(
|
||||
pid=pid,
|
||||
user_repo=user_repo,
|
||||
package_version=selected_version,
|
||||
meta=meta,
|
||||
release_version=release_version,
|
||||
release_items=release_items,
|
||||
)
|
||||
if plan is None:
|
||||
if selection is None:
|
||||
return False, message
|
||||
|
||||
release_tag = plan.release_tag
|
||||
if release_tag and not plan.fallback_to_filelist:
|
||||
release_tag = selection.release_tag
|
||||
if release_tag and not selection.fallback_to_filelist:
|
||||
def prepare_selected_release() -> tuple[bool, str]:
|
||||
return self.__install_from_release(
|
||||
pid,
|
||||
user_repo,
|
||||
selection.user_repo,
|
||||
release_tag,
|
||||
)
|
||||
|
||||
@@ -919,14 +981,18 @@ class PluginPackageManager:
|
||||
def prepare_release() -> tuple[bool, str]:
|
||||
ok, msg = self.__install_from_release(
|
||||
pid,
|
||||
user_repo,
|
||||
selection.user_repo,
|
||||
release_tag,
|
||||
)
|
||||
if ok:
|
||||
return True, msg
|
||||
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
|
||||
self.__remove_old_plugin(pid)
|
||||
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
|
||||
return self.__prepare_content_via_filelist_sync(
|
||||
pid,
|
||||
selection.user_repo,
|
||||
selection.package_version,
|
||||
)
|
||||
|
||||
return self.__install_flow_sync(
|
||||
pid,
|
||||
@@ -937,7 +1003,11 @@ class PluginPackageManager:
|
||||
)
|
||||
# 未声明 release 打包的插件继续使用文件列表方式安装。
|
||||
def prepare_filelist() -> tuple[bool, str]:
|
||||
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
|
||||
return self.__prepare_content_via_filelist_sync(
|
||||
pid,
|
||||
selection.user_repo,
|
||||
selection.package_version,
|
||||
)
|
||||
|
||||
return self.__install_flow_sync(
|
||||
pid,
|
||||
@@ -1754,58 +1824,47 @@ class PluginPackageManager:
|
||||
),
|
||||
)
|
||||
|
||||
if SystemUtils.is_frozen():
|
||||
return False, "可执行文件模式下,只能安装本地插件"
|
||||
user_repo, error_result = self._resolve_remote_plugin_repository(
|
||||
pid, repo_url
|
||||
)
|
||||
if error_result is not None:
|
||||
return error_result
|
||||
assert user_repo is not None
|
||||
|
||||
# 验证参数
|
||||
if not pid or not repo_url:
|
||||
return False, "参数错误"
|
||||
|
||||
# 从 GitHub 的 repo_url 获取用户和项目名
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return False, "不支持的插件仓库地址格式"
|
||||
|
||||
user_repo = f"{user}/{repo}"
|
||||
|
||||
if not package_version:
|
||||
package_version = get_runtime_setting('VERSION_FLAG')
|
||||
|
||||
# 1. 优先检查指定版本的插件
|
||||
package_version = await self.async_get_plugin_package_version(pid, repo_url, package_version)
|
||||
# 如果 package_version 为None,说明没有找到匹配的插件
|
||||
if package_version is None:
|
||||
msg = f"{pid} 没有找到适用于当前版本的插件"
|
||||
logger.debug(msg)
|
||||
return False, msg
|
||||
# package_version 为空,表示从 package.json 中找到插件
|
||||
elif package_version == "":
|
||||
logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件")
|
||||
else:
|
||||
logger.debug(f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件")
|
||||
preferred_version = package_version or get_runtime_setting('VERSION_FLAG')
|
||||
selected_version = await self.async_get_plugin_package_version(
|
||||
pid, repo_url, preferred_version
|
||||
)
|
||||
if error_result := self._accept_remote_package_version(
|
||||
pid, selected_version
|
||||
):
|
||||
return error_result
|
||||
assert selected_version is not None
|
||||
|
||||
# 2. 统一异步安装流程(release 或文件列表)。
|
||||
meta = await self.__async_get_plugin_meta(pid, repo_url, package_version)
|
||||
meta = await self.__async_get_plugin_meta(pid, repo_url, selected_version)
|
||||
release_items = (
|
||||
await self.async_get_plugin_release_versions(pid, repo_url)
|
||||
if release_version
|
||||
else []
|
||||
)
|
||||
plan, message = self._build_remote_plugin_install_plan(
|
||||
selection, message = self._select_remote_install(
|
||||
pid=pid,
|
||||
user_repo=user_repo,
|
||||
package_version=selected_version,
|
||||
meta=meta,
|
||||
release_version=release_version,
|
||||
release_items=release_items,
|
||||
)
|
||||
if plan is None:
|
||||
if selection is None:
|
||||
return False, message
|
||||
|
||||
release_tag = plan.release_tag
|
||||
if release_tag and not plan.fallback_to_filelist:
|
||||
release_tag = selection.release_tag
|
||||
if release_tag and not selection.fallback_to_filelist:
|
||||
async def prepare_selected_release() -> tuple[bool, str]:
|
||||
return await self.__async_install_from_release(
|
||||
pid,
|
||||
user_repo,
|
||||
selection.user_repo,
|
||||
release_tag,
|
||||
)
|
||||
|
||||
@@ -1822,14 +1881,18 @@ class PluginPackageManager:
|
||||
async def prepare_release() -> tuple[bool, str]:
|
||||
ok, msg = await self.__async_install_from_release(
|
||||
pid,
|
||||
user_repo,
|
||||
selection.user_repo,
|
||||
release_tag,
|
||||
)
|
||||
if ok:
|
||||
return True, msg
|
||||
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
|
||||
await self.__async_remove_old_plugin(pid)
|
||||
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
|
||||
return await self.__prepare_content_via_filelist_async(
|
||||
pid,
|
||||
selection.user_repo,
|
||||
selection.package_version,
|
||||
)
|
||||
|
||||
return await self.__install_flow_async(
|
||||
pid,
|
||||
@@ -1840,7 +1903,11 @@ class PluginPackageManager:
|
||||
)
|
||||
# 未声明 release 打包的插件继续使用文件列表方式安装。
|
||||
async def prepare_filelist() -> tuple[bool, str]:
|
||||
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
|
||||
return await self.__prepare_content_via_filelist_async(
|
||||
pid,
|
||||
selection.user_repo,
|
||||
selection.package_version,
|
||||
)
|
||||
|
||||
return await self.__install_flow_async(
|
||||
pid,
|
||||
|
||||
293
tests/test_plugin_adapter_parity.py
Normal file
293
tests/test_plugin_adapter_parity.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""插件市场、包安装和依赖安装同步异步一致性测试。"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.external.plugin.client import PluginMarketTransport
|
||||
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
|
||||
REPO_URL = "https://github.com/example/moviepilot-plugins"
|
||||
SYNC_INDEX_REQUEST = "_PluginMarketTransport__request_plugin_index_with_fallback"
|
||||
ASYNC_INDEX_REQUEST = (
|
||||
"_PluginMarketTransport__async_request_plugin_index_with_fallback"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected", "error"),
|
||||
[
|
||||
((404, ""), None, None),
|
||||
((200, '{"DemoPlugin": {"version": "1.2.3"}}'),
|
||||
{"DemoPlugin": {"version": "1.2.3"}}, None),
|
||||
(None, None, "插件索引请求失败:连接失败"),
|
||||
((500, "upstream failed"), None, "插件索引请求失败:HTTP 500"),
|
||||
((200, "not-json"), None, "插件索引响应格式无效"),
|
||||
],
|
||||
)
|
||||
async def test_plugin_index_sync_async_result_contract_is_identical(
|
||||
monkeypatch,
|
||||
response: tuple[int, str] | None,
|
||||
expected: dict | None,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
"""同步与异步索引传输必须经过同一响应分类和错误语义。"""
|
||||
transport = PluginMarketTransport()
|
||||
|
||||
def request(_url: str, *, headers: dict) -> tuple[int, str] | None:
|
||||
return response
|
||||
|
||||
async def async_request(
|
||||
_url: str, *, headers: dict
|
||||
) -> tuple[int, str] | None:
|
||||
return response
|
||||
|
||||
monkeypatch.setattr(transport, SYNC_INDEX_REQUEST, request)
|
||||
monkeypatch.setattr(transport, ASYNC_INDEX_REQUEST, async_request)
|
||||
transport.get_plugin_index_result.cache_clear()
|
||||
await transport.async_get_plugin_index_result.cache_clear()
|
||||
|
||||
if error is not None:
|
||||
with pytest.raises(RuntimeError, match=error):
|
||||
transport.get_plugin_index_result(REPO_URL, "v3")
|
||||
with pytest.raises(RuntimeError, match=error):
|
||||
await transport.async_get_plugin_index_result(REPO_URL, "v3")
|
||||
return
|
||||
|
||||
sync_result = transport.get_plugin_index_result(REPO_URL, "v3")
|
||||
async_result = await transport.async_get_plugin_index_result(REPO_URL, "v3")
|
||||
|
||||
assert sync_result == async_result == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("plugin_id", "repo_url", "frozen", "repo_info", "selected", "expected"),
|
||||
[
|
||||
("DemoPlugin", REPO_URL, True, ("example", "repo"), "v3",
|
||||
(False, "可执行文件模式下,只能安装本地插件")),
|
||||
("", REPO_URL, False, ("example", "repo"), "v3",
|
||||
(False, "参数错误")),
|
||||
("DemoPlugin", "invalid", False, (None, None), "v3",
|
||||
(False, "不支持的插件仓库地址格式")),
|
||||
("DemoPlugin", REPO_URL, False, ("example", "repo"), None,
|
||||
(False, "DemoPlugin 没有找到适用于当前版本的插件")),
|
||||
],
|
||||
)
|
||||
async def test_plugin_package_sync_async_preflight_failures_are_identical(
|
||||
monkeypatch,
|
||||
plugin_id: str,
|
||||
repo_url: str,
|
||||
frozen: bool,
|
||||
repo_info: tuple[str | None, str | None],
|
||||
selected: str | None,
|
||||
expected: tuple[bool, str],
|
||||
) -> None:
|
||||
"""远端包安装的环境、参数、仓库和代际失败必须保持同一结果。"""
|
||||
manager = PluginPackageManager(source=Mock())
|
||||
monkeypatch.setattr(manager, "is_local_repo_url", lambda _repo_url: False)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.SystemUtils.is_frozen",
|
||||
lambda: frozen,
|
||||
)
|
||||
monkeypatch.setattr(manager, "get_repo_info", lambda _repo_url: repo_info)
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"get_plugin_package_version",
|
||||
lambda *_args: selected,
|
||||
)
|
||||
|
||||
async def async_get_plugin_package_version(*_args) -> str | None:
|
||||
return selected
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"async_get_plugin_package_version",
|
||||
async_get_plugin_package_version,
|
||||
)
|
||||
|
||||
sync_result = manager.install_raw(plugin_id, repo_url, package_version="v3")
|
||||
async_result = await manager.async_install_raw(
|
||||
plugin_id, repo_url, package_version="v3"
|
||||
)
|
||||
|
||||
assert sync_result == async_result == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("metadata", "release_version", "release_items", "expected_calls"),
|
||||
[
|
||||
({"version": "1.2.3", "release": False}, None, [], ["filelist"]),
|
||||
(
|
||||
{"version": "1.2.3", "release": True},
|
||||
None,
|
||||
[],
|
||||
["release", "remove", "filelist"],
|
||||
),
|
||||
(
|
||||
{"version": "1.2.3", "release": True},
|
||||
"1.2.3",
|
||||
[{"version": "1.2.3"}],
|
||||
["release"],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_plugin_package_sync_async_execute_same_selected_strategy(
|
||||
monkeypatch,
|
||||
metadata: dict,
|
||||
release_version: str | None,
|
||||
release_items: list[dict],
|
||||
expected_calls: list[str],
|
||||
) -> None:
|
||||
"""共享安装选择必须让同步与异步外壳执行相同 Release 回退路径。"""
|
||||
manager = PluginPackageManager(source=Mock())
|
||||
sync_calls: list[str] = []
|
||||
async_calls: list[str] = []
|
||||
monkeypatch.setattr(manager, "is_local_repo_url", lambda _repo_url: False)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.SystemUtils.is_frozen",
|
||||
lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager, "get_repo_info", lambda _repo_url: ("example", "repo")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager, "get_plugin_package_version", lambda *_args: "v3"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager, "get_plugin_release_versions", lambda *_args: release_items
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
manager, "_PluginPackageManager__get_plugin_meta", lambda *_args: metadata
|
||||
)
|
||||
|
||||
async def selected_version(*_args) -> str:
|
||||
return "v3"
|
||||
|
||||
async def releases(*_args) -> list[dict]:
|
||||
return release_items
|
||||
|
||||
async def meta(*_args) -> dict:
|
||||
return metadata
|
||||
|
||||
def install_release(*_args) -> tuple[bool, str]:
|
||||
sync_calls.append("release")
|
||||
return (True, "installed") if release_version else (False, "missing asset")
|
||||
|
||||
async def async_install_release(*_args) -> tuple[bool, str]:
|
||||
async_calls.append("release")
|
||||
return (True, "installed") if release_version else (False, "missing asset")
|
||||
|
||||
def prepare_filelist(*_args) -> tuple[bool, str]:
|
||||
sync_calls.append("filelist")
|
||||
return True, "installed"
|
||||
|
||||
async def async_prepare_filelist(*_args) -> tuple[bool, str]:
|
||||
async_calls.append("filelist")
|
||||
return True, "installed"
|
||||
|
||||
def remove(*_args) -> None:
|
||||
sync_calls.append("remove")
|
||||
|
||||
async def async_remove(*_args) -> None:
|
||||
async_calls.append("remove")
|
||||
|
||||
def install_flow(_pid, _force, prepare, _repo_url, _before):
|
||||
return prepare()
|
||||
|
||||
async def async_install_flow(_pid, _force, prepare, _repo_url, _before):
|
||||
return await prepare()
|
||||
|
||||
monkeypatch.setattr(manager, "async_get_plugin_package_version", selected_version)
|
||||
monkeypatch.setattr(manager, "async_get_plugin_release_versions", releases)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__async_get_plugin_meta", meta)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__install_from_release", install_release)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__async_install_from_release", async_install_release)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__prepare_content_via_filelist_sync", prepare_filelist)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__prepare_content_via_filelist_async", async_prepare_filelist)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__remove_old_plugin", remove)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__async_remove_old_plugin", async_remove)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__install_flow_sync", install_flow)
|
||||
monkeypatch.setattr(manager, "_PluginPackageManager__install_flow_async", async_install_flow)
|
||||
|
||||
sync_result = manager.install_raw(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
package_version="v3",
|
||||
release_version=release_version,
|
||||
)
|
||||
async_result = await manager.async_install_raw(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
package_version="v3",
|
||||
release_version=release_version,
|
||||
)
|
||||
|
||||
assert sync_result == async_result == (True, "installed")
|
||||
assert sync_calls == async_calls == expected_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_install_sync_async_share_request_and_result(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""依赖安装必须向同步与异步包端口提交同一份规范请求。"""
|
||||
manifest_path = tmp_path / "requirements.txt"
|
||||
wheels_dir = tmp_path / "wheels"
|
||||
packages = SimpleNamespace(
|
||||
install_packages_with_fallback=Mock(return_value=(True, "installed")),
|
||||
async_install_packages_with_fallback=AsyncMock(
|
||||
return_value=(True, "installed")
|
||||
),
|
||||
)
|
||||
installer = PluginDependencyInstaller(packages, plugin_dir=tmp_path)
|
||||
monkeypatch.setattr(
|
||||
installer,
|
||||
"_plugin_manifests",
|
||||
lambda: [SimpleNamespace(path=manifest_path)],
|
||||
)
|
||||
monkeypatch.setattr(installer, "_wheels_dirs", lambda: [wheels_dir])
|
||||
|
||||
sync_result = installer.install(["demo>=1"])
|
||||
async_result = await installer.async_install(["demo>=1"])
|
||||
|
||||
assert sync_result == async_result == (True, "installed")
|
||||
packages.install_packages_with_fallback.assert_called_once_with(
|
||||
[manifest_path], [wheels_dir]
|
||||
)
|
||||
packages.async_install_packages_with_fallback.assert_awaited_once_with(
|
||||
[manifest_path], [wheels_dir]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_install_sync_async_share_error_mapping(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""清单准备和包端口异常必须映射为完全一致的公开失败结果。"""
|
||||
packages = SimpleNamespace(
|
||||
install_packages_with_fallback=Mock(side_effect=OSError("disk full")),
|
||||
async_install_packages_with_fallback=AsyncMock(
|
||||
side_effect=OSError("disk full")
|
||||
),
|
||||
)
|
||||
installer = PluginDependencyInstaller(packages, plugin_dir=tmp_path)
|
||||
monkeypatch.setattr(
|
||||
installer,
|
||||
"_plugin_manifests",
|
||||
lambda: [SimpleNamespace(path=tmp_path / "requirements.txt")],
|
||||
)
|
||||
monkeypatch.setattr(installer, "_wheels_dirs", lambda: [])
|
||||
|
||||
sync_result = installer.install(["demo>=1"])
|
||||
async_result = await installer.async_install(["demo>=1"])
|
||||
|
||||
assert sync_result == async_result == (
|
||||
False,
|
||||
"安装依赖项时发生错误:disk full",
|
||||
)
|
||||
Reference in New Issue
Block a user