refactor(plugin): unify adapter decisions

This commit is contained in:
jxxghp
2026-08-29 21:23:12 +08:00
parent 157934abe5
commit 93a8dca5e9
4 changed files with 515 additions and 120 deletions

View File

@@ -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]:

View File

@@ -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)

View File

@@ -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,

View 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",
)