mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
816 lines
26 KiB
Python
816 lines
26 KiB
Python
"""
|
||
验证插件依赖安装参数、结果反馈与失败隔离。
|
||
"""
|
||
|
||
import asyncio
|
||
import threading
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, Mock
|
||
|
||
import pytest
|
||
from packaging.requirements import Requirement
|
||
from packaging.utils import canonicalize_name
|
||
from packaging.version import Version
|
||
|
||
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
|
||
from app.adapters.system.plugin.manifest import load_dependency_file
|
||
|
||
|
||
def _write_requirements(root: Path, plugin_id: str, content: str) -> None:
|
||
"""
|
||
写入一个测试插件的 requirements 文件。
|
||
"""
|
||
plugin_dir = root / plugin_id.lower()
|
||
plugin_dir.mkdir(parents=True)
|
||
(plugin_dir / "requirements.txt").write_text(content, encoding="utf-8")
|
||
|
||
|
||
def _write_pyproject(root: Path, plugin_id: str, content: str) -> Path:
|
||
"""
|
||
写入一个测试插件的 pyproject 依赖清单。
|
||
"""
|
||
plugin_dir = root / plugin_id.lower()
|
||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||
pyproject_file = plugin_dir / "pyproject.toml"
|
||
pyproject_file.write_text(content, encoding="utf-8")
|
||
return plugin_dir
|
||
|
||
|
||
def test_classify_plugins_preserves_ids_and_separates_startup_paths(
|
||
tmp_path,
|
||
monkeypatch,
|
||
):
|
||
"""
|
||
启动分类保留规范插件 ID,并区分可加载、缺依赖和缺源码。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
(plugin_root / "readyplugin").mkdir(parents=True)
|
||
_write_requirements(plugin_root, "DependencyPending", "demo>=2\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: [
|
||
"ReadyPlugin",
|
||
"DependencyPending",
|
||
"SourcePending",
|
||
],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_packages",
|
||
lambda: {"demo": Version("1.0")},
|
||
)
|
||
|
||
ready, missing_dependencies, missing_source = installer.classify_plugins()
|
||
|
||
assert ready == ["ReadyPlugin"]
|
||
assert missing_dependencies == ["DependencyPending"]
|
||
assert missing_source == ["SourcePending"]
|
||
|
||
|
||
def test_find_missing_merges_only_installed_plugin_constraints(tmp_path, monkeypatch):
|
||
"""
|
||
依赖扫描只覆盖安装清单,并合并同名包的多插件约束。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_requirements(plugin_root, "Alpha", "Demo-Pkg>=2\n")
|
||
_write_requirements(plugin_root, "Beta", "demo.pkg<4\n")
|
||
_write_requirements(plugin_root, "Ignored", "unused>=1\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_packages",
|
||
lambda: {"demo_pkg": Version("1.0")},
|
||
)
|
||
|
||
missing = installer.find_missing()
|
||
|
||
assert len(missing) == 1
|
||
assert missing[0].startswith("demo_pkg")
|
||
assert ">=2" in missing[0]
|
||
assert "<4" in missing[0]
|
||
assert all("unused" not in item for item in missing)
|
||
|
||
|
||
def test_find_missing_skips_satisfied_constraints(tmp_path, monkeypatch):
|
||
"""
|
||
已安装版本满足合并约束时不得重复调用 pip。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_requirements(plugin_root, "Alpha", "demo>=1,<3\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_packages",
|
||
lambda: {"demo": Version("2.0")},
|
||
)
|
||
|
||
assert installer.find_missing() == []
|
||
|
||
|
||
def test_find_missing_preserves_merged_extras(tmp_path, monkeypatch):
|
||
"""
|
||
同一包的多插件约束合并后必须保留全部 extras。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_requirements(plugin_root, "Alpha", "Demo-Pkg[alpha]>=2\n")
|
||
_write_requirements(plugin_root, "Beta", "demo.pkg[beta]<4\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
missing = installer.find_missing()
|
||
|
||
assert len(missing) == 1
|
||
requirement = Requirement(missing[0])
|
||
assert requirement.name == "demo_pkg"
|
||
assert requirement.extras == {"alpha", "beta"}
|
||
assert ">=2" in str(requirement.specifier)
|
||
assert "<4" in str(requirement.specifier)
|
||
|
||
|
||
def test_find_missing_preserves_direct_url(tmp_path, monkeypatch):
|
||
"""
|
||
缺失的 direct URL 依赖必须按原安装来源返回。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
direct_url = "https://example.com/packages/demo_pkg-2.0.0-py3-none-any.whl"
|
||
_write_requirements(
|
||
plugin_root,
|
||
"Alpha",
|
||
f"Demo-Pkg[feature] @ {direct_url}\n",
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
missing = installer.find_missing()
|
||
|
||
assert len(missing) == 1
|
||
requirement = Requirement(missing[0])
|
||
assert canonicalize_name(requirement.name) == canonicalize_name("Demo-Pkg")
|
||
assert requirement.extras == {"feature"}
|
||
assert requirement.url == direct_url
|
||
|
||
|
||
def test_find_missing_does_not_accept_base_package_for_extra(
|
||
tmp_path, monkeypatch
|
||
):
|
||
"""
|
||
已安装基础包但未安装其 extra 依赖时必须继续恢复。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_requirements(plugin_root, "Alpha", "Demo[feature]>=1\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer, "_installed_packages", lambda: {"demo": Version("2.0")}
|
||
)
|
||
metadata = SimpleNamespace(
|
||
get_all=lambda key: {"Provides-Extra": ["feature"], "Requires-Dist": [
|
||
"feature-dependency>=1; extra == 'feature'"
|
||
]}.get(key, []),
|
||
)
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_distribution",
|
||
lambda package_name: SimpleNamespace(metadata=metadata)
|
||
if package_name == "demo"
|
||
else None,
|
||
)
|
||
|
||
assert installer.find_missing() == ["demo[feature]>=1"]
|
||
|
||
|
||
def test_find_missing_accepts_satisfied_extra_dependencies(tmp_path, monkeypatch):
|
||
"""
|
||
已安装 extra 及其依赖时不得重复恢复。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_requirements(plugin_root, "Alpha", "Demo[feature]>=1\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_packages",
|
||
lambda: {"demo": Version("2.0"), "feature_dependency": Version("1.2")},
|
||
)
|
||
metadata = SimpleNamespace(
|
||
get_all=lambda key: {"Provides-Extra": ["feature"], "Requires-Dist": [
|
||
"feature-dependency>=1; extra == 'feature'"
|
||
]}.get(key, []),
|
||
)
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_distribution",
|
||
lambda package_name: SimpleNamespace(metadata=metadata)
|
||
if package_name == "demo"
|
||
else None,
|
||
)
|
||
|
||
assert installer.find_missing() == []
|
||
|
||
|
||
def test_find_missing_rejects_missing_transitive_extra_dependency(
|
||
tmp_path, monkeypatch
|
||
):
|
||
"""
|
||
extra 的传递依赖缺失时不能只因根包已安装就跳过恢复。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_requirements(plugin_root, "Alpha", "Demo[feature]>=1\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_packages",
|
||
lambda: {"demo": Version("2.0"), "bridge": Version("1.0")},
|
||
)
|
||
metadata_by_name = {
|
||
"demo": SimpleNamespace(
|
||
metadata=SimpleNamespace(
|
||
get_all=lambda key: {
|
||
"Provides-Extra": ["feature"],
|
||
"Requires-Dist": ["bridge>=1; extra == 'feature'"],
|
||
}.get(key, [])
|
||
)
|
||
),
|
||
"bridge": SimpleNamespace(
|
||
metadata=SimpleNamespace(
|
||
get_all=lambda key: {
|
||
"Requires-Dist": ["leaf>=1"],
|
||
}.get(key, [])
|
||
)
|
||
),
|
||
}
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_distribution",
|
||
lambda package_name: metadata_by_name.get(package_name),
|
||
)
|
||
|
||
assert installer.find_missing() == ["demo[feature]>=1"]
|
||
|
||
|
||
def test_find_missing_rejects_same_name_package_from_wrong_direct_url(
|
||
tmp_path, monkeypatch
|
||
):
|
||
"""
|
||
存在不同 PEP 610 来源时,同名包不能满足 direct URL 依赖。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
required_url = "https://example.com/packages/demo-2.0.0-py3-none-any.whl"
|
||
installed_url = "https://mirror.example.com/packages/demo-2.0.0-py3-none-any.whl"
|
||
_write_requirements(plugin_root, "Alpha", f"Demo @ {required_url}\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer, "_installed_packages", lambda: {"demo": Version("2.0")}
|
||
)
|
||
metadata = SimpleNamespace(get_all=lambda _key: [])
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_distribution",
|
||
lambda _package_name: SimpleNamespace(
|
||
metadata=metadata,
|
||
read_text=lambda _name: '{"url": "' + installed_url + '"}',
|
||
),
|
||
)
|
||
|
||
assert installer.find_missing() == [f"demo @ {required_url}"]
|
||
|
||
|
||
def test_find_missing_accepts_matching_direct_url(tmp_path, monkeypatch):
|
||
"""
|
||
同名包且 PEP 610 来源一致时应视为已满足。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
direct_url = "https://example.com/packages/demo-2.0.0-py3-none-any.whl"
|
||
_write_requirements(plugin_root, "Alpha", f"Demo @ {direct_url}\n")
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(
|
||
installer, "_installed_packages", lambda: {"demo": Version("2.0")}
|
||
)
|
||
metadata = SimpleNamespace(get_all=lambda _key: [])
|
||
monkeypatch.setattr(
|
||
installer,
|
||
"_installed_distribution",
|
||
lambda _package_name: SimpleNamespace(
|
||
metadata=metadata,
|
||
read_text=lambda _name: '{"url": "' + direct_url + '"}',
|
||
),
|
||
)
|
||
|
||
assert installer.find_missing() == []
|
||
|
||
|
||
def test_find_missing_prefers_pyproject_project_dependencies(
|
||
tmp_path,
|
||
monkeypatch,
|
||
):
|
||
"""
|
||
现代清单优先,且只消费 project.dependencies。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
plugin_dir = _write_pyproject(
|
||
plugin_root,
|
||
"Alpha",
|
||
"""
|
||
[project]
|
||
name = "alpha"
|
||
version = "1.0.0"
|
||
dependencies = ["Modern-Pkg>=2"]
|
||
|
||
[dependency-groups]
|
||
dev = ["group-only>=1"]
|
||
""",
|
||
)
|
||
(plugin_dir / "requirements.txt").write_text(
|
||
"legacy-only>=1\n",
|
||
encoding="utf-8",
|
||
)
|
||
(plugin_dir / "uv.lock").write_text(
|
||
'package = [{ name = "lock-only", version = "1.0.0" }]\n',
|
||
encoding="utf-8",
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
missing = installer.find_missing()
|
||
|
||
assert missing == ["modern_pkg>=2"]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"pyproject",
|
||
[
|
||
"[project\n",
|
||
'[project]\ndependencies = "demo>=2"\n',
|
||
'[project]\ndependencies = ["not a requirement !!!"]\n',
|
||
'[project]\ndynamic = ["dependencies"]\n',
|
||
],
|
||
)
|
||
def test_find_missing_fails_closed_for_invalid_pyproject(
|
||
tmp_path,
|
||
monkeypatch,
|
||
pyproject,
|
||
):
|
||
"""
|
||
现代清单无效时不得回退并消费旧 requirements。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
plugin_dir = _write_pyproject(plugin_root, "Alpha", pyproject)
|
||
(plugin_dir / "requirements.txt").write_text(
|
||
"legacy-only>=1\n",
|
||
encoding="utf-8",
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
with pytest.raises(ValueError, match="pyproject.toml"):
|
||
installer.find_missing()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"pyproject",
|
||
[
|
||
'[project]\nversion = "1.0.0"\ndependencies = ["demo>=2"]\n',
|
||
'[project]\nname = "alpha"\ndependencies = ["demo>=2"]\n',
|
||
'[project]\nname = " "\nversion = "1.0.0"\n'
|
||
'dependencies = ["demo>=2"]\n',
|
||
'[project]\nname = "alpha"\nversion = " "\n'
|
||
'dependencies = ["demo>=2"]\n',
|
||
],
|
||
)
|
||
def test_find_missing_fails_closed_without_required_project_identity(
|
||
tmp_path,
|
||
monkeypatch,
|
||
pyproject,
|
||
):
|
||
"""
|
||
现代清单缺少 uv 消费所需的 name 或 version 时必须拒绝安装。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_pyproject(plugin_root, "Alpha", pyproject)
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
with pytest.raises(ValueError, match="pyproject.toml"):
|
||
installer.find_missing()
|
||
|
||
|
||
def test_find_missing_accepts_dynamic_project_version(tmp_path, monkeypatch):
|
||
"""
|
||
version 由构建后端动态提供时仍可消费静态 dependencies。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
_write_pyproject(
|
||
plugin_root,
|
||
"Alpha",
|
||
'[project]\nname = "alpha"\ndynamic = ["version"]\n'
|
||
'dependencies = ["demo>=2"]\n',
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
Mock(),
|
||
installed_plugins_provider=lambda: ["Alpha"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
assert installer.find_missing() == ["demo>=2"]
|
||
|
||
|
||
def test_load_dependency_file_accepts_custom_legacy_filename(tmp_path):
|
||
"""
|
||
临时或自定义命名的旧格式依赖文件复用统一解析器。
|
||
"""
|
||
dependency_file = tmp_path / "plugin-dependencies.txt"
|
||
dependency_file.write_text("Demo-Pkg>=2\n", encoding="utf-8")
|
||
|
||
manifest = load_dependency_file(dependency_file)
|
||
|
||
assert manifest.path == dependency_file
|
||
assert [str(requirement) for requirement in manifest.dependencies] == [
|
||
"Demo-Pkg>=2"
|
||
]
|
||
|
||
|
||
def test_install_passes_all_active_manifests_to_one_install(tmp_path):
|
||
"""
|
||
缺失依赖恢复必须保留 modern 与 legacy 清单的原始内容。
|
||
"""
|
||
plugin_root = tmp_path / "plugins"
|
||
modern_dir = _write_pyproject(
|
||
plugin_root,
|
||
"Alpha",
|
||
"""
|
||
[project]
|
||
name = "alpha"
|
||
version = "1.0.0"
|
||
dependencies = ["demo>=2"]
|
||
|
||
[[tool.uv.index]]
|
||
name = "private"
|
||
url = "https://packages.example/simple"
|
||
explicit = true
|
||
|
||
[tool.uv.sources]
|
||
demo = { index = "private" }
|
||
""",
|
||
)
|
||
_write_requirements(
|
||
plugin_root,
|
||
"Beta",
|
||
"--extra-index-url https://legacy.example/simple\nother\n",
|
||
)
|
||
helper = Mock()
|
||
helper.install_packages_with_fallback.return_value = (True, "installed")
|
||
installer = PluginDependencyInstaller(
|
||
helper,
|
||
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||
plugin_dir=plugin_root,
|
||
)
|
||
|
||
result = installer.install([
|
||
"demo[feature] @ https://example.com/demo.whl",
|
||
"other",
|
||
])
|
||
|
||
assert result == (True, "installed")
|
||
manifest_paths = helper.install_packages_with_fallback.call_args.args[0]
|
||
assert manifest_paths == [
|
||
modern_dir / "pyproject.toml",
|
||
plugin_root / "beta" / "requirements.txt",
|
||
]
|
||
assert "[tool.uv.sources]" in manifest_paths[0].read_text(encoding="utf-8")
|
||
assert "--extra-index-url" in manifest_paths[1].read_text(encoding="utf-8")
|
||
|
||
|
||
def test_disabled_plugins_do_not_contribute_dependencies_or_wheels(
|
||
tmp_path, monkeypatch
|
||
):
|
||
"""
|
||
停用插件的版本钉子、无效清单和 wheels 都不得进入恢复输入。
|
||
"""
|
||
_write_requirements(tmp_path, "Enabled", "demo==2\n")
|
||
_write_requirements(tmp_path, "Disabled", "demo==1\n")
|
||
_write_pyproject(tmp_path, "Invalid", "[project\n")
|
||
enabled_wheels = tmp_path / "enabled" / "wheels"
|
||
enabled_wheels.mkdir()
|
||
(tmp_path / "disabled" / "wheels").mkdir()
|
||
enabled = {"Enabled"}
|
||
packages = Mock()
|
||
packages.install_packages_with_fallback.return_value = (True, "installed")
|
||
installer = PluginDependencyInstaller(
|
||
packages,
|
||
installed_plugins_provider=lambda: ["Enabled", "Disabled", "Invalid"],
|
||
enabled_plugins_provider=lambda: enabled,
|
||
plugin_dir=tmp_path,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
assert installer.find_missing() == ["demo==2"]
|
||
assert installer.install(installer.find_missing()) == (True, "installed")
|
||
packages.install_packages_with_fallback.assert_called_once_with(
|
||
[tmp_path / "enabled" / "requirements.txt"], [enabled_wheels]
|
||
)
|
||
|
||
enabled.clear()
|
||
assert installer.find_missing() == []
|
||
assert installer._wheels_dirs() == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
@pytest.mark.parametrize("already_ready", [False, True])
|
||
async def test_conflicting_plugin_does_not_block_others_or_replace_ready_pin(
|
||
tmp_path, monkeypatch, use_async, already_ready
|
||
):
|
||
"""
|
||
冲突降级时保护已就绪和刚恢复的插件,并继续恢复无关插件。
|
||
"""
|
||
_write_requirements(tmp_path, "Alpha", "demo==1\n")
|
||
_write_requirements(tmp_path, "Beta", "demo==2\n")
|
||
_write_requirements(tmp_path, "Other", "unrelated>=1\n")
|
||
installed = {"demo": Version("2")} if already_ready else {}
|
||
calls = []
|
||
|
||
def install(paths, _wheels):
|
||
"""
|
||
模拟解析器拒绝互斥约束,不访问真实 Python 环境或网络。
|
||
"""
|
||
calls.append([path.parent.name for path in paths])
|
||
requirements = [
|
||
requirement
|
||
for path in paths
|
||
for requirement in load_dependency_file(path).dependencies
|
||
]
|
||
pins = {str(item.specifier) for item in requirements if item.name == "demo"}
|
||
if len(pins) > 1:
|
||
return False, "No solution found when resolving dependencies"
|
||
for item in requirements:
|
||
installed[item.name] = Version("2" if str(item.specifier) == "==2" else "1")
|
||
return True, "installed"
|
||
|
||
packages = SimpleNamespace(
|
||
install_packages_with_fallback=Mock(side_effect=install),
|
||
async_install_packages_with_fallback=AsyncMock(side_effect=install),
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
packages,
|
||
installed_plugins_provider=lambda: ["Alpha", "Beta", "Other"],
|
||
plugin_dir=tmp_path,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: dict(installed))
|
||
monkeypatch.setattr(installer, "_installed_distribution", lambda _name: None)
|
||
missing = installer.find_missing()
|
||
|
||
result = (
|
||
await installer.async_install(missing)
|
||
if use_async else installer.install(missing)
|
||
)
|
||
|
||
assert result[0] is False
|
||
assert ("alpha" if already_ready else "beta") in result[1]
|
||
assert installed["demo"] == Version("2" if already_ready else "1")
|
||
assert installed["unrelated"] == Version("1")
|
||
assert calls == (
|
||
[["alpha", "beta", "other"], ["beta", "alpha"], ["beta", "other"]]
|
||
if already_ready else
|
||
[
|
||
["alpha", "beta", "other"], ["alpha"],
|
||
["alpha", "beta"], ["alpha", "other"],
|
||
]
|
||
)
|
||
ready, pending, _source_missing = installer.classify_plugins()
|
||
assert ready == (["Beta", "Other"] if already_ready else ["Alpha", "Other"])
|
||
assert pending == (["Alpha"] if already_ready else ["Beta"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
async def test_group_failure_or_exception_keeps_other_manifests_recoverable(
|
||
tmp_path, monkeypatch, use_async
|
||
):
|
||
"""
|
||
单组异常也不阻断后续组,原始 modern/legacy 来源声明保持完整。
|
||
"""
|
||
_write_requirements(tmp_path, "Broken", "broken>=1\n")
|
||
_write_requirements(
|
||
tmp_path, "Legacy", "--extra-index-url https://example.com\nlegacy\n"
|
||
)
|
||
modern = _write_pyproject(
|
||
tmp_path, "Modern",
|
||
'[project]\nname="modern"\nversion="1"\ndependencies=["modern"]\n'
|
||
'[tool.uv.sources]\nmodern={url="https://example.com/modern.whl"}\n',
|
||
)
|
||
calls = []
|
||
|
||
def install(paths, _wheels):
|
||
"""
|
||
拒绝包含坏插件的请求,记录后续原始清单输入。
|
||
"""
|
||
calls.append(paths)
|
||
if len(calls) == 1:
|
||
return False, "batch failed"
|
||
if paths[0].parent.name == "broken":
|
||
raise OSError("group failed")
|
||
return True, "installed"
|
||
|
||
packages = SimpleNamespace(
|
||
install_packages_with_fallback=Mock(side_effect=install),
|
||
async_install_packages_with_fallback=AsyncMock(side_effect=install),
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
packages,
|
||
installed_plugins_provider=lambda: ["Broken", "Legacy", "Modern"],
|
||
plugin_dir=tmp_path,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
dependencies = ["broken", "legacy", "modern"]
|
||
result = (
|
||
await installer.async_install(dependencies)
|
||
if use_async else installer.install(dependencies)
|
||
)
|
||
|
||
assert result[0] is False
|
||
assert "broken" in result[1]
|
||
assert "group failed" in result[1]
|
||
assert calls[-1] == [
|
||
tmp_path / "legacy" / "requirements.txt", modern / "pyproject.toml"
|
||
]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("use_async", [False, True])
|
||
async def test_fallback_reports_success_when_every_group_recovers(
|
||
tmp_path, monkeypatch, use_async
|
||
):
|
||
"""
|
||
批量失败但所有分组均恢复后,同步和异步结果都报告成功。
|
||
"""
|
||
for plugin_id in ("Alpha", "Beta"):
|
||
_write_requirements(tmp_path, plugin_id, f"{plugin_id.lower()}>=1\n")
|
||
results = [(False, "batch failed"), (True, "alpha"), (True, "beta")]
|
||
packages = SimpleNamespace(
|
||
install_packages_with_fallback=Mock(side_effect=results),
|
||
async_install_packages_with_fallback=AsyncMock(side_effect=results),
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
packages,
|
||
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||
plugin_dir=tmp_path,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
result = (
|
||
await installer.async_install(["alpha", "beta"])
|
||
if use_async else installer.install(["alpha", "beta"])
|
||
)
|
||
assert result == (True, "插件依赖已分组恢复")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_fallback_cancellation_stops_remaining_groups(tmp_path, monkeypatch):
|
||
"""
|
||
取消分组安装必须传播到启动调用方,后续插件不得继续写环境。
|
||
"""
|
||
for plugin_id in ("Alpha", "Beta"):
|
||
_write_requirements(tmp_path, plugin_id, f"{plugin_id.lower()}>=1\n")
|
||
packages = SimpleNamespace(
|
||
async_install_packages_with_fallback=AsyncMock(
|
||
side_effect=[(False, "batch failed"), asyncio.CancelledError()]
|
||
),
|
||
)
|
||
installer = PluginDependencyInstaller(
|
||
packages,
|
||
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||
plugin_dir=tmp_path,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await installer.async_install(["alpha", "beta"])
|
||
|
||
assert packages.async_install_packages_with_fallback.await_count == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_install_inspection_runs_outside_event_loop(tmp_path, monkeypatch):
|
||
"""
|
||
请求准备和失败分组扫描都由托管 worker 执行,不阻塞宿主事件循环。
|
||
"""
|
||
for plugin_id in ("Alpha", "Beta"):
|
||
_write_requirements(tmp_path, plugin_id, f"{plugin_id.lower()}>=1\n")
|
||
packages = SimpleNamespace(async_install_packages_with_fallback=AsyncMock(
|
||
side_effect=[(False, "batch failed"), (True, "alpha"), (True, "beta")]
|
||
))
|
||
installer = PluginDependencyInstaller(
|
||
packages,
|
||
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||
plugin_dir=tmp_path,
|
||
)
|
||
monkeypatch.setattr(installer, "_installed_packages", lambda: {})
|
||
inspections = []
|
||
|
||
def record(operation):
|
||
"""
|
||
保留真实扫描行为并记录执行线程。
|
||
"""
|
||
def inspect(*args):
|
||
"""
|
||
记录当前检查操作的线程身份。
|
||
"""
|
||
inspections.append((operation.__name__, threading.get_ident()))
|
||
return operation(*args)
|
||
return inspect
|
||
|
||
monkeypatch.setattr(installer, "_prepare_install_request", record(
|
||
installer._prepare_install_request
|
||
))
|
||
monkeypatch.setattr(installer, "_fallback_plan", record(installer._fallback_plan))
|
||
assert (await installer.async_install(["alpha", "beta"]))[0] is True
|
||
assert [name for name, _thread in inspections] == [
|
||
"_prepare_install_request", "_fallback_plan"
|
||
]
|
||
assert all(thread != threading.get_ident() for _name, thread in inspections)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_install_waits_for_inspection_before_propagating_cancel(
|
||
tmp_path, monkeypatch
|
||
):
|
||
"""
|
||
取消请求等待在途扫描收敛,再向上抛出,且不启动任何包写入。
|
||
"""
|
||
started = asyncio.Event()
|
||
release = threading.Event()
|
||
loop = asyncio.get_running_loop()
|
||
packages = SimpleNamespace(async_install_packages_with_fallback=AsyncMock())
|
||
installer = PluginDependencyInstaller(packages, plugin_dir=tmp_path)
|
||
|
||
def prepare(_dependencies):
|
||
"""
|
||
用受控只读扫描模拟取消发生时仍在途的 worker。
|
||
"""
|
||
loop.call_soon_threadsafe(started.set)
|
||
assert release.wait(timeout=5)
|
||
return None, (False, "no manifest")
|
||
|
||
monkeypatch.setattr(installer, "_prepare_install_request", prepare)
|
||
task = asyncio.create_task(installer.async_install(["demo"]))
|
||
try:
|
||
await asyncio.wait_for(started.wait(), timeout=5)
|
||
task.cancel()
|
||
await asyncio.sleep(0)
|
||
assert not task.done()
|
||
packages.async_install_packages_with_fallback.assert_not_awaited()
|
||
finally:
|
||
release.set()
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await task
|
||
packages.async_install_packages_with_fallback.assert_not_awaited()
|