mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
207 lines
8.3 KiB
Python
207 lines
8.3 KiB
Python
"""插件 requirements 聚合和 Python 依赖安装适配器。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Callable
|
||
from importlib.metadata import distributions
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
from packaging.requirements import Requirement
|
||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||
from packaging.version import InvalidVersion, Version
|
||
|
||
from app.runtime.config import settings
|
||
from app.runtime.log import logger
|
||
|
||
|
||
class PluginDependencyInstaller:
|
||
"""独立负责插件依赖扫描、约束合并和 pip 安装。"""
|
||
|
||
def __init__(
|
||
self,
|
||
helper: Any = None,
|
||
*,
|
||
installed_plugins_provider: Optional[Callable[[], list[str]]] = None,
|
||
plugin_dir: Optional[Path] = None,
|
||
) -> None:
|
||
"""保存 pip 端口和启动层提供的已安装插件读取器。"""
|
||
if helper is None:
|
||
from app.adapters.external.market import PluginHelper
|
||
|
||
helper = PluginHelper()
|
||
self._helper = helper
|
||
self._installed_plugins_provider = installed_plugins_provider or (lambda: [])
|
||
self._plugin_dir = plugin_dir or (
|
||
Path(settings.ROOT_PATH) / "app" / "plugins"
|
||
)
|
||
|
||
@staticmethod
|
||
def _standardize(name: str) -> str:
|
||
"""按 PEP 503 兼容规则标准化依赖包名。"""
|
||
return (name or "").lower().replace("-", "_").replace(".", "_")
|
||
|
||
@classmethod
|
||
def _installed_packages(cls) -> dict[str, Version]:
|
||
"""读取当前 Python 环境中可解析版本的已安装包。"""
|
||
installed: dict[str, Version] = {}
|
||
try:
|
||
for distribution in distributions():
|
||
name = distribution.metadata.get("Name")
|
||
version = distribution.metadata.get("Version") or getattr(
|
||
distribution,
|
||
"version",
|
||
None,
|
||
)
|
||
if not name or not version:
|
||
continue
|
||
package_name = cls._standardize(name)
|
||
try:
|
||
parsed = Version(version)
|
||
except InvalidVersion:
|
||
logger.debug(
|
||
f"无法解析已安装包 '{package_name}' 的版本:{version}"
|
||
)
|
||
continue
|
||
if package_name not in installed or parsed > installed[package_name]:
|
||
installed[package_name] = parsed
|
||
except Exception as err:
|
||
logger.error(f"获取已安装的包时发生错误:{err}")
|
||
return installed
|
||
|
||
@classmethod
|
||
def _parse_requirements(cls, requirements_file: Path) -> dict[str, list[str]]:
|
||
"""解析一个 requirements 文件中的包名和版本约束。"""
|
||
dependencies: dict[str, list[str]] = {}
|
||
try:
|
||
for line in requirements_file.read_text(
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
).splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
try:
|
||
requirement = Requirement(line)
|
||
except Exception as err:
|
||
logger.debug(f"无法解析依赖项 '{line}':{err}")
|
||
continue
|
||
package_name = cls._standardize(requirement.name)
|
||
dependencies.setdefault(package_name, []).append(
|
||
str(requirement.specifier)
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"解析 requirements.txt 时发生错误:{err}")
|
||
return dependencies
|
||
|
||
@classmethod
|
||
def _merge(cls, dependencies: dict[str, set[str]]) -> dict[str, str]:
|
||
"""求同一包多来源约束的交集,保留冲突约束供 pip 处理。"""
|
||
merged: dict[str, str] = {}
|
||
for package_name, specifiers in dependencies.items():
|
||
spec_set = SpecifierSet()
|
||
for specifier in specifiers:
|
||
if not specifier:
|
||
continue
|
||
try:
|
||
spec_set &= SpecifierSet(specifier)
|
||
except InvalidSpecifier as err:
|
||
logger.error(f"发生版本约束冲突:{err}")
|
||
merged[package_name] = str(spec_set) if spec_set else ""
|
||
return merged
|
||
|
||
def _plugin_dependencies(self) -> dict[str, str]:
|
||
"""扫描已安装插件的 requirements 并合并版本约束。"""
|
||
dependencies: dict[str, set[str]] = {}
|
||
installed_plugins = {
|
||
plugin_id.lower()
|
||
for plugin_id in self._installed_plugins_provider() or []
|
||
}
|
||
try:
|
||
plugin_dirs = list(self._plugin_dir.iterdir())
|
||
except (FileNotFoundError, OSError):
|
||
return {}
|
||
for plugin_dir in plugin_dirs:
|
||
if not plugin_dir.is_dir():
|
||
continue
|
||
requirements_file = plugin_dir / "requirements.txt"
|
||
if not requirements_file.is_file():
|
||
continue
|
||
if plugin_dir.name not in installed_plugins:
|
||
logger.debug(f"忽略插件 {plugin_dir.name} 的依赖")
|
||
continue
|
||
for package_name, specifiers in self._parse_requirements(
|
||
requirements_file
|
||
).items():
|
||
dependencies.setdefault(package_name, set()).update(specifiers)
|
||
return self._merge(dependencies)
|
||
|
||
def find_missing(self) -> list[str]:
|
||
"""返回当前插件集合缺失或不满足约束的依赖项。"""
|
||
try:
|
||
required = self._plugin_dependencies()
|
||
installed = self._installed_packages()
|
||
missing = []
|
||
for package_name, specifier in required.items():
|
||
installed_version = installed.get(package_name)
|
||
try:
|
||
satisfied = installed_version is not None and SpecifierSet(
|
||
specifier
|
||
).contains(installed_version, prereleases=True)
|
||
except InvalidSpecifier as err:
|
||
logger.error(f"依赖 {package_name} 约束无效:{err}")
|
||
satisfied = False
|
||
if not satisfied:
|
||
missing.append(f"{package_name}{specifier}")
|
||
return missing
|
||
except Exception as err:
|
||
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{err}")
|
||
return []
|
||
|
||
def _wheels_dirs(self) -> list[Path]:
|
||
"""收集已安装插件附带的本地 wheels 目录。"""
|
||
result = []
|
||
installed_plugins = {
|
||
plugin_id.lower()
|
||
for plugin_id in self._installed_plugins_provider() or []
|
||
}
|
||
for plugin_id in installed_plugins:
|
||
wheels_dir = self._plugin_dir / plugin_id / "wheels"
|
||
if wheels_dir.is_dir():
|
||
result.append(wheels_dir)
|
||
return list(dict.fromkeys(result))
|
||
|
||
def install(self, dependencies: list[str]) -> tuple[bool, str]:
|
||
"""把依赖写入临时 requirements 并调用现有 pip 健康检查策略。"""
|
||
if not dependencies:
|
||
return False, "没有传入需要安装的依赖项"
|
||
requirements_file = (
|
||
Path(settings.TEMP_PATH)
|
||
/ "plugin_dependencies"
|
||
/ "requirements.txt"
|
||
)
|
||
try:
|
||
requirements_file.parent.mkdir(parents=True, exist_ok=True)
|
||
requirements_file.write_text(
|
||
"".join(f"{dependency}\n" for dependency in dependencies),
|
||
encoding="utf-8",
|
||
)
|
||
return self._helper.pip_install_with_fallback(
|
||
requirements_file,
|
||
self._wheels_dirs(),
|
||
)
|
||
except Exception as err:
|
||
logger.error(f"安装依赖项时发生错误:{err}")
|
||
return False, f"安装依赖项时发生错误:{err}"
|
||
finally:
|
||
requirements_file.unlink(missing_ok=True)
|
||
|
||
async def async_find_missing(self) -> list[str]:
|
||
"""在线程池中扫描缺失依赖,避免阻塞事件循环。"""
|
||
return await asyncio.to_thread(self.find_missing)
|
||
|
||
async def async_install(self, dependencies: list[str]) -> tuple[bool, str]:
|
||
"""在线程池中安装依赖,复用同步 pip 健康检查策略。"""
|
||
return await asyncio.to_thread(self.install, dependencies)
|