mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
fix(plugins): preserve dependency manifest sources (#6370)
This commit is contained in:
Vendored
+29
-13
@@ -14,7 +14,7 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable
|
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable, Sequence
|
||||||
from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit
|
from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@@ -1461,7 +1461,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def __build_package_install_request(
|
def __build_package_install_request(
|
||||||
cls,
|
cls,
|
||||||
dependency_file: Path,
|
dependency_files: Path | Sequence[Path],
|
||||||
find_links_dirs: Optional[List[Path]] = None,
|
find_links_dirs: Optional[List[Path]] = None,
|
||||||
constraints_file: Optional[Path] = None,
|
constraints_file: Optional[Path] = None,
|
||||||
purpose: str = "plugin",
|
purpose: str = "plugin",
|
||||||
@@ -1469,8 +1469,12 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
"""
|
"""
|
||||||
将 MoviePilot 运行配置转换为 uv 安装请求,统一缓存、镜像和代理语义。
|
将 MoviePilot 运行配置转换为 uv 安装请求,统一缓存、镜像和代理语义。
|
||||||
"""
|
"""
|
||||||
|
if isinstance(dependency_files, Path):
|
||||||
|
resolved_dependency_files = (dependency_files,)
|
||||||
|
else:
|
||||||
|
resolved_dependency_files = tuple(Path(item) for item in dependency_files)
|
||||||
return PackageInstallRequest(
|
return PackageInstallRequest(
|
||||||
dependency_file=dependency_file,
|
dependency_files=resolved_dependency_files,
|
||||||
python_bin=Path(sys.executable),
|
python_bin=Path(sys.executable),
|
||||||
find_links_dirs=find_links_dirs or [],
|
find_links_dirs=find_links_dirs or [],
|
||||||
constraints_file=constraints_file,
|
constraints_file=constraints_file,
|
||||||
@@ -1583,18 +1587,26 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def install_packages_with_fallback(cls,
|
def install_packages_with_fallback(cls,
|
||||||
dependency_file: Path,
|
dependency_files: Path | Sequence[Path],
|
||||||
find_links_dirs: Optional[List[Path]] = None) -> Tuple[bool, str]:
|
find_links_dirs: Optional[List[Path]] = None) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
使用自动降级策略安装依赖,并确保新安装的包可被动态导入
|
使用自动降级策略安装依赖,并确保新安装的包可被动态导入
|
||||||
:param dependency_file: 插件依赖清单路径
|
:param dependency_files: 一个或多个插件依赖清单路径
|
||||||
:param find_links_dirs: 额外的本地 wheels 目录列表
|
:param find_links_dirs: 额外的本地 wheels 目录列表
|
||||||
:return: (是否成功, 错误信息)
|
:return: (是否成功, 错误信息)
|
||||||
"""
|
"""
|
||||||
wheels_dir = dependency_file.parent / "wheels"
|
if isinstance(dependency_files, Path):
|
||||||
|
resolved_dependency_files = (dependency_files,)
|
||||||
|
else:
|
||||||
|
resolved_dependency_files = tuple(Path(item) for item in dependency_files)
|
||||||
|
if not resolved_dependency_files:
|
||||||
|
return False, "没有传入插件依赖清单"
|
||||||
|
|
||||||
candidate_dirs = []
|
candidate_dirs = []
|
||||||
if wheels_dir.is_dir():
|
for dependency_file in resolved_dependency_files:
|
||||||
candidate_dirs.append(wheels_dir)
|
wheels_dir = dependency_file.parent / "wheels"
|
||||||
|
if wheels_dir.is_dir():
|
||||||
|
candidate_dirs.append(wheels_dir)
|
||||||
if find_links_dirs:
|
if find_links_dirs:
|
||||||
candidate_dirs.extend(find_links_dirs)
|
candidate_dirs.extend(find_links_dirs)
|
||||||
|
|
||||||
@@ -1619,10 +1631,14 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
|
|
||||||
installed_packages = cls.__get_installed_packages()
|
installed_packages = cls.__get_installed_packages()
|
||||||
protected_packages = cls.__get_protected_runtime_packages(installed_packages)
|
protected_packages = cls.__get_protected_runtime_packages(installed_packages)
|
||||||
check_ok, check_message = cls.__validate_runtime_dependency_conflicts(dependency_file, protected_packages)
|
for dependency_file in resolved_dependency_files:
|
||||||
if not check_ok:
|
check_ok, check_message = cls.__validate_runtime_dependency_conflicts(
|
||||||
logger.error(f"[UV] 运行环境冲突预检失败:{check_message}")
|
dependency_file,
|
||||||
return False, check_message
|
protected_packages,
|
||||||
|
)
|
||||||
|
if not check_ok:
|
||||||
|
logger.error(f"[UV] 运行环境冲突预检失败:{check_message}")
|
||||||
|
return False, check_message
|
||||||
|
|
||||||
constraints_file = None
|
constraints_file = None
|
||||||
if protected_packages:
|
if protected_packages:
|
||||||
@@ -1633,7 +1649,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
return False, f"创建运行环境约束文件失败:{e}"
|
return False, f"创建运行环境约束文件失败:{e}"
|
||||||
|
|
||||||
request = cls.__build_package_install_request(
|
request = cls.__build_package_install_request(
|
||||||
dependency_file,
|
resolved_dependency_files,
|
||||||
find_links_dirs=resolved_dirs,
|
find_links_dirs=resolved_dirs,
|
||||||
constraints_file=constraints_file,
|
constraints_file=constraints_file,
|
||||||
purpose="plugin",
|
purpose="plugin",
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ from urllib.parse import urlsplit, urlunsplit
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PackageInstallRequest:
|
class PackageInstallRequest:
|
||||||
"""
|
"""
|
||||||
Python 包安装请求,集中描述依赖文件、工具缓存、代理和本地 wheels 候选源。
|
Python 包安装请求,集中描述依赖清单、工具缓存、代理和本地 wheels 候选源。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
dependency_file: Path
|
dependency_files: tuple[Path, ...]
|
||||||
python_bin: Path
|
python_bin: Path
|
||||||
find_links_dirs: list[Path] = field(default_factory=list)
|
find_links_dirs: list[Path] = field(default_factory=list)
|
||||||
constraints_file: Path | None = None
|
constraints_file: Path | None = None
|
||||||
@@ -92,7 +92,8 @@ def _base_install_args(request: PackageInstallRequest) -> list[str]:
|
|||||||
args.extend(["--find-links", str(directory)])
|
args.extend(["--find-links", str(directory)])
|
||||||
if request.constraints_file:
|
if request.constraints_file:
|
||||||
args.extend(["-c", str(request.constraints_file)])
|
args.extend(["-c", str(request.constraints_file)])
|
||||||
args.extend(["-r", str(request.dependency_file)])
|
for dependency_file in request.dependency_files:
|
||||||
|
args.extend(["-r", str(dependency_file)])
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
@@ -119,11 +120,14 @@ def _build_uv_command(uv_bin: Path, request: PackageInstallRequest, use_index: b
|
|||||||
|
|
||||||
|
|
||||||
def _build_uv_sync_command(uv_bin: Path, request: PackageInstallRequest, use_index: bool) -> list[str]:
|
def _build_uv_sync_command(uv_bin: Path, request: PackageInstallRequest, use_index: bool) -> list[str]:
|
||||||
|
if len(request.dependency_files) != 1:
|
||||||
|
raise ValueError("主项目锁定依赖恢复只接受一个 pyproject.toml")
|
||||||
|
project_file = request.dependency_files[0]
|
||||||
command = [
|
command = [
|
||||||
str(uv_bin),
|
str(uv_bin),
|
||||||
"sync",
|
"sync",
|
||||||
"--project",
|
"--project",
|
||||||
str(request.dependency_file.parent),
|
str(project_file.parent),
|
||||||
"--locked",
|
"--locked",
|
||||||
"--no-dev",
|
"--no-dev",
|
||||||
"--no-install-project",
|
"--no-install-project",
|
||||||
|
|||||||
@@ -248,9 +248,9 @@ class PluginDependencyInstaller:
|
|||||||
merged.append(Requirement(target))
|
merged.append(Requirement(target))
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
def _plugin_dependencies(self) -> list[Requirement]:
|
def _plugin_manifests(self) -> list[Any]:
|
||||||
"""扫描已安装插件的生效依赖清单并合并版本约束。"""
|
"""返回已安装插件当前生效的依赖清单。"""
|
||||||
dependencies: list[Requirement] = []
|
manifests = []
|
||||||
installed_plugins = {
|
installed_plugins = {
|
||||||
plugin_id.lower()
|
plugin_id.lower()
|
||||||
for plugin_id in self._installed_plugins_provider() or []
|
for plugin_id in self._installed_plugins_provider() or []
|
||||||
@@ -259,7 +259,7 @@ class PluginDependencyInstaller:
|
|||||||
plugin_dirs = list(self._plugin_dir.iterdir())
|
plugin_dirs = list(self._plugin_dir.iterdir())
|
||||||
except (FileNotFoundError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
return []
|
return []
|
||||||
for plugin_dir in plugin_dirs:
|
for plugin_dir in sorted(plugin_dirs, key=lambda item: item.name):
|
||||||
if not plugin_dir.is_dir():
|
if not plugin_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
if plugin_dir.name not in installed_plugins:
|
if plugin_dir.name not in installed_plugins:
|
||||||
@@ -268,6 +268,13 @@ class PluginDependencyInstaller:
|
|||||||
manifest = load_dependency_manifest(plugin_dir)
|
manifest = load_dependency_manifest(plugin_dir)
|
||||||
if manifest is None:
|
if manifest is None:
|
||||||
continue
|
continue
|
||||||
|
manifests.append(manifest)
|
||||||
|
return manifests
|
||||||
|
|
||||||
|
def _plugin_dependencies(self) -> list[Requirement]:
|
||||||
|
"""扫描已安装插件的生效依赖清单并合并版本约束。"""
|
||||||
|
dependencies: list[Requirement] = []
|
||||||
|
for manifest in self._plugin_manifests():
|
||||||
for requirement in manifest.dependencies:
|
for requirement in manifest.dependencies:
|
||||||
if requirement.marker and not requirement.marker.evaluate():
|
if requirement.marker and not requirement.marker.evaluate():
|
||||||
continue
|
continue
|
||||||
@@ -304,29 +311,20 @@ class PluginDependencyInstaller:
|
|||||||
return list(dict.fromkeys(result))
|
return list(dict.fromkeys(result))
|
||||||
|
|
||||||
def install(self, dependencies: list[str]) -> tuple[bool, str]:
|
def install(self, dependencies: list[str]) -> tuple[bool, str]:
|
||||||
"""把依赖写入临时 requirements 并调用统一包安装策略。"""
|
"""把已安装插件的原始清单交给一次统一包安装。"""
|
||||||
if not dependencies:
|
if not dependencies:
|
||||||
return False, "没有传入需要安装的依赖项"
|
return False, "没有传入需要安装的依赖项"
|
||||||
requirements_file = (
|
|
||||||
Path(settings.TEMP_PATH)
|
|
||||||
/ "plugin_dependencies"
|
|
||||||
/ "requirements.txt"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
requirements_file.parent.mkdir(parents=True, exist_ok=True)
|
manifest_paths = [manifest.path for manifest in self._plugin_manifests()]
|
||||||
requirements_file.write_text(
|
if not manifest_paths:
|
||||||
"".join(f"{dependency}\n" for dependency in dependencies),
|
return False, "没有找到已安装插件的依赖清单"
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
return self._helper.install_packages_with_fallback(
|
return self._helper.install_packages_with_fallback(
|
||||||
requirements_file,
|
manifest_paths,
|
||||||
self._wheels_dirs(),
|
self._wheels_dirs(),
|
||||||
)
|
)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.error(f"安装依赖项时发生错误:{err}")
|
logger.error(f"安装依赖项时发生错误:{err}")
|
||||||
return False, f"安装依赖项时发生错误:{err}"
|
return False, f"安装依赖项时发生错误:{err}"
|
||||||
finally:
|
|
||||||
requirements_file.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
async def async_find_missing(self) -> list[str]:
|
async def async_find_missing(self) -> list[str]:
|
||||||
"""在线程池中扫描缺失依赖,避免阻塞事件循环。"""
|
"""在线程池中扫描缺失依赖,避免阻塞事件循环。"""
|
||||||
|
|||||||
@@ -34,24 +34,24 @@ def main() -> None:
|
|||||||
|
|
||||||
samples = {
|
samples = {
|
||||||
"plain": PackageInstallRequest(
|
"plain": PackageInstallRequest(
|
||||||
dependency_file=requirements,
|
dependency_files=(requirements,),
|
||||||
python_bin=python_bin,
|
python_bin=python_bin,
|
||||||
config_dir=config_dir,
|
config_dir=config_dir,
|
||||||
),
|
),
|
||||||
"mirror": PackageInstallRequest(
|
"mirror": PackageInstallRequest(
|
||||||
dependency_file=requirements,
|
dependency_files=(requirements,),
|
||||||
python_bin=python_bin,
|
python_bin=python_bin,
|
||||||
config_dir=config_dir,
|
config_dir=config_dir,
|
||||||
package_index_url="https://user:pass@mirror.example/simple",
|
package_index_url="https://user:pass@mirror.example/simple",
|
||||||
),
|
),
|
||||||
"proxy": PackageInstallRequest(
|
"proxy": PackageInstallRequest(
|
||||||
dependency_file=requirements,
|
dependency_files=(requirements,),
|
||||||
python_bin=python_bin,
|
python_bin=python_bin,
|
||||||
config_dir=config_dir,
|
config_dir=config_dir,
|
||||||
proxy_url="http://proxy.example:7890",
|
proxy_url="http://proxy.example:7890",
|
||||||
),
|
),
|
||||||
"mirror_proxy_wheels": PackageInstallRequest(
|
"mirror_proxy_wheels": PackageInstallRequest(
|
||||||
dependency_file=requirements,
|
dependency_files=(requirements,),
|
||||||
python_bin=python_bin,
|
python_bin=python_bin,
|
||||||
config_dir=config_dir,
|
config_dir=config_dir,
|
||||||
find_links_dirs=[
|
find_links_dirs=[
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def test_build_env_maps_proxy_and_cache(tmp_path, monkeypatch):
|
|||||||
monkeypatch.delenv("PACKAGE_CACHE_ROOT", raising=False)
|
monkeypatch.delenv("PACKAGE_CACHE_ROOT", raising=False)
|
||||||
monkeypatch.setenv("HTTP_PROXY", "http://old.example:8080")
|
monkeypatch.setenv("HTTP_PROXY", "http://old.example:8080")
|
||||||
request = PackageInstallRequest(
|
request = PackageInstallRequest(
|
||||||
dependency_file=tmp_path / "requirements.txt",
|
dependency_files=(tmp_path / "requirements.txt",),
|
||||||
python_bin=Path("/venv/bin/python"),
|
python_bin=Path("/venv/bin/python"),
|
||||||
config_dir=tmp_path / "config",
|
config_dir=tmp_path / "config",
|
||||||
package_index_url="https://user:pass@mirror.example/simple",
|
package_index_url="https://user:pass@mirror.example/simple",
|
||||||
@@ -38,7 +38,7 @@ def test_build_env_uses_package_cache_root_and_preserves_tool_cache_overrides(tm
|
|||||||
monkeypatch.setenv("PACKAGE_CACHE_ROOT", str(tmp_path / "custom-package-cache"))
|
monkeypatch.setenv("PACKAGE_CACHE_ROOT", str(tmp_path / "custom-package-cache"))
|
||||||
monkeypatch.delenv("UV_CACHE_DIR", raising=False)
|
monkeypatch.delenv("UV_CACHE_DIR", raising=False)
|
||||||
request = PackageInstallRequest(
|
request = PackageInstallRequest(
|
||||||
dependency_file=tmp_path / "requirements.txt",
|
dependency_files=(tmp_path / "requirements.txt",),
|
||||||
python_bin=Path("/venv/bin/python"),
|
python_bin=Path("/venv/bin/python"),
|
||||||
config_dir=tmp_path / "config",
|
config_dir=tmp_path / "config",
|
||||||
)
|
)
|
||||||
@@ -59,7 +59,7 @@ def test_build_strategies_prefers_uv_network_matrix_and_preserves_find_links(tmp
|
|||||||
uv_bin.write_text("", encoding="utf-8")
|
uv_bin.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
request = PackageInstallRequest(
|
request = PackageInstallRequest(
|
||||||
dependency_file=req,
|
dependency_files=(req,),
|
||||||
python_bin=tmp_path / "venv" / "bin" / "python",
|
python_bin=tmp_path / "venv" / "bin" / "python",
|
||||||
find_links_dirs=[wheels],
|
find_links_dirs=[wheels],
|
||||||
config_dir=tmp_path / "config",
|
config_dir=tmp_path / "config",
|
||||||
@@ -92,7 +92,7 @@ def test_build_strategies_fail_closed_when_uv_missing(tmp_path):
|
|||||||
req = tmp_path / "requirements.txt"
|
req = tmp_path / "requirements.txt"
|
||||||
req.write_text("demo\n", encoding="utf-8")
|
req.write_text("demo\n", encoding="utf-8")
|
||||||
request = PackageInstallRequest(
|
request = PackageInstallRequest(
|
||||||
dependency_file=req,
|
dependency_files=(req,),
|
||||||
python_bin=tmp_path / "venv" / "bin" / "python",
|
python_bin=tmp_path / "venv" / "bin" / "python",
|
||||||
config_dir=tmp_path / "config",
|
config_dir=tmp_path / "config",
|
||||||
)
|
)
|
||||||
@@ -103,6 +103,32 @@ def test_build_strategies_fail_closed_when_uv_missing(tmp_path):
|
|||||||
assert strategies == []
|
assert strategies == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_strategies_passes_all_manifests_to_one_uv_process(tmp_path):
|
||||||
|
"""多个插件清单必须进入同一个 uv 命令并保持输入顺序。"""
|
||||||
|
modern = tmp_path / "modern" / "pyproject.toml"
|
||||||
|
modern.parent.mkdir()
|
||||||
|
modern.write_text("[project]\nname='modern'\nversion='1'\n", encoding="utf-8")
|
||||||
|
legacy = tmp_path / "legacy" / "requirements.txt"
|
||||||
|
legacy.parent.mkdir()
|
||||||
|
legacy.write_text("demo\n", encoding="utf-8")
|
||||||
|
uv_bin = tmp_path / "venv" / "bin" / "uv"
|
||||||
|
uv_bin.parent.mkdir(parents=True)
|
||||||
|
uv_bin.write_text("", encoding="utf-8")
|
||||||
|
request = PackageInstallRequest(
|
||||||
|
dependency_files=(modern, legacy),
|
||||||
|
python_bin=tmp_path / "venv" / "bin" / "python",
|
||||||
|
)
|
||||||
|
|
||||||
|
strategies = build_package_install_strategies(request)
|
||||||
|
|
||||||
|
command = strategies[0].command
|
||||||
|
first_requirement = command.index("-r")
|
||||||
|
second_requirement = command.index("-r", first_requirement + 1)
|
||||||
|
assert command.count("-r") == 2
|
||||||
|
assert command[first_requirement + 1] == str(modern)
|
||||||
|
assert command[second_requirement + 1] == str(legacy)
|
||||||
|
|
||||||
|
|
||||||
def test_redact_url_removes_userinfo():
|
def test_redact_url_removes_userinfo():
|
||||||
assert redact_url("https://user:pass@mirror.example/simple") == "https://mirror.example/simple"
|
assert redact_url("https://user:pass@mirror.example/simple") == "https://mirror.example/simple"
|
||||||
|
|
||||||
|
|||||||
@@ -408,24 +408,38 @@ def test_load_dependency_file_accepts_custom_legacy_filename(tmp_path):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_install_uses_adapter_owned_temporary_requirements(tmp_path, monkeypatch):
|
def test_install_passes_all_active_manifests_to_one_install(tmp_path):
|
||||||
"""批量依赖文件由依赖适配器创建并在安装返回后清理。"""
|
"""缺失依赖恢复必须保留 modern 与 legacy 清单的原始内容。"""
|
||||||
helper = Mock()
|
plugin_root = tmp_path / "plugins"
|
||||||
installed_contents = []
|
modern_dir = _write_pyproject(
|
||||||
|
plugin_root,
|
||||||
|
"Alpha",
|
||||||
|
"""
|
||||||
|
[project]
|
||||||
|
name = "alpha"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = ["demo>=2"]
|
||||||
|
|
||||||
def _install_packages(dependency_file, _wheels_dirs):
|
[[tool.uv.index]]
|
||||||
installed_contents.append(dependency_file.read_text(encoding="utf-8"))
|
name = "private"
|
||||||
return True, "installed"
|
url = "https://packages.example/simple"
|
||||||
|
explicit = true
|
||||||
|
|
||||||
helper.install_packages_with_fallback.side_effect = _install_packages
|
[tool.uv.sources]
|
||||||
monkeypatch.setattr(
|
demo = { index = "private" }
|
||||||
"app.adapters.system.plugin.dependency.settings",
|
""",
|
||||||
SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"),
|
|
||||||
)
|
)
|
||||||
|
_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(
|
installer = PluginDependencyInstaller(
|
||||||
helper,
|
helper,
|
||||||
installed_plugins_provider=lambda: [],
|
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||||||
plugin_dir=tmp_path / "plugins",
|
plugin_dir=plugin_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = installer.install([
|
result = installer.install([
|
||||||
@@ -434,9 +448,10 @@ def test_install_uses_adapter_owned_temporary_requirements(tmp_path, monkeypatch
|
|||||||
])
|
])
|
||||||
|
|
||||||
assert result == (True, "installed")
|
assert result == (True, "installed")
|
||||||
assert installed_contents == [
|
manifest_paths = helper.install_packages_with_fallback.call_args.args[0]
|
||||||
"demo[feature] @ https://example.com/demo.whl\nother\n"
|
assert manifest_paths == [
|
||||||
|
modern_dir / "pyproject.toml",
|
||||||
|
plugin_root / "beta" / "requirements.txt",
|
||||||
]
|
]
|
||||||
requirements_file = helper.install_packages_with_fallback.call_args.args[0]
|
assert "[tool.uv.sources]" in manifest_paths[0].read_text(encoding="utf-8")
|
||||||
assert requirements_file.name == "requirements.txt"
|
assert "--extra-index-url" in manifest_paths[1].read_text(encoding="utf-8")
|
||||||
assert not requirements_file.exists()
|
|
||||||
|
|||||||
@@ -1062,6 +1062,75 @@ class TestPluginHelper:
|
|||||||
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
|
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
|
||||||
assert "user:pass" not in " ".join(safe_command)
|
assert "user:pass" not in " ".join(safe_command)
|
||||||
|
|
||||||
|
def test_uv_install_keeps_multiple_original_manifests_in_one_command(self):
|
||||||
|
"""批量恢复必须让 uv 直接读取每个插件的原始生效清单。"""
|
||||||
|
try:
|
||||||
|
from app.adapters.external.market import PluginHelper
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
pytest.skip(f"missing dependency: {exc}")
|
||||||
|
|
||||||
|
seen_commands = []
|
||||||
|
|
||||||
|
def fake_execute(command, env=None, safe_command=None):
|
||||||
|
seen_commands.append(command)
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
modern = root / "modern" / "pyproject.toml"
|
||||||
|
modern.parent.mkdir()
|
||||||
|
modern.write_text(
|
||||||
|
"""
|
||||||
|
[project]
|
||||||
|
name = "modern"
|
||||||
|
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" }
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
legacy = root / "legacy" / "requirements.txt"
|
||||||
|
legacy.parent.mkdir()
|
||||||
|
legacy.write_text(
|
||||||
|
"--extra-index-url https://legacy.example/simple\nother\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
uv_bin = _create_fake_uv(root)
|
||||||
|
|
||||||
|
with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \
|
||||||
|
patch.object(PluginHelper, "_PluginHelper__get_installed_packages", return_value={}), \
|
||||||
|
patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \
|
||||||
|
patch.object(
|
||||||
|
PluginHelper,
|
||||||
|
"_PluginHelper__run_runtime_healthcheck",
|
||||||
|
return_value={"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")},
|
||||||
|
), \
|
||||||
|
patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute):
|
||||||
|
success, message = PluginHelper.install_packages_with_fallback(
|
||||||
|
[modern, legacy]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success
|
||||||
|
assert message == "ok"
|
||||||
|
install_command = next(
|
||||||
|
command for command in seen_commands
|
||||||
|
if command[:3] == [str(uv_bin), "pip", "install"]
|
||||||
|
)
|
||||||
|
requirement_positions = [
|
||||||
|
index for index, value in enumerate(install_command) if value == "-r"
|
||||||
|
]
|
||||||
|
assert [install_command[index + 1] for index in requirement_positions] == [
|
||||||
|
str(modern),
|
||||||
|
str(legacy),
|
||||||
|
]
|
||||||
|
|
||||||
def test_uv_install_serializes_concurrent_calls(self):
|
def test_uv_install_serializes_concurrent_calls(self):
|
||||||
"""
|
"""
|
||||||
验证多个依赖安装请求会复用同一把锁串行执行 uv。
|
验证多个依赖安装请求会复用同一把锁串行执行 uv。
|
||||||
|
|||||||
Reference in New Issue
Block a user