from pathlib import Path from types import SimpleNamespace from unittest.mock import 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")