feat(deps): add uv-backed package installer (#5987)

* feat(deps): add uv-backed package installer

* feat(deps): support package cache root
This commit is contained in:
InfinityPacer
2026-06-23 13:36:15 +08:00
committed by GitHub
parent 126279c63b
commit 0c53fb86fd
21 changed files with 1654 additions and 62 deletions

View File

@@ -325,11 +325,16 @@ def _best_effort_auto_update() -> None:
]
update_env = os.environ.copy()
package_cache_root = Path(update_env.get("PACKAGE_CACHE_ROOT", "").strip() or settings.PACKAGE_CACHE_PATH)
update_env.setdefault("PACKAGE_CACHE_ROOT", str(package_cache_root))
update_env.setdefault("PIP_CACHE_DIR", str(package_cache_root / "pip"))
update_env.setdefault("UV_CACHE_DIR", str(package_cache_root / "uv"))
if settings.PIP_PROXY:
update_env["PIP_PROXY"] = settings.PIP_PROXY
if settings.PROXY_HOST:
update_env.setdefault("http_proxy", settings.PROXY_HOST)
update_env.setdefault("https_proxy", settings.PROXY_HOST)
update_env.setdefault("HTTP_PROXY", settings.PROXY_HOST)
update_env.setdefault("HTTPS_PROXY", settings.PROXY_HOST)
update_env["PROXY_HOST"] = settings.PROXY_HOST
for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"):
update_env[key] = settings.PROXY_HOST
if settings.GITHUB_TOKEN:
update_env.setdefault("GITHUB_TOKEN", settings.GITHUB_TOKEN)

View File

@@ -170,6 +170,10 @@ class ConfigModel(BaseModel):
GLOBAL_IMAGE_CACHE_DAYS: int = 7
# 临时文件保留天数
TEMP_FILE_DAYS: int = 3
# pip/uv 包下载缓存保留天数
PACKAGE_CACHE_DAYS: int = 90
# pip/uv 包下载缓存根目录,留空时使用配置目录下的 .cache
PACKAGE_CACHE_ROOT: Optional[str] = None
# 元数据识别缓存过期时间小时0为自动
META_CACHE_EXPIRE: int = 0
@@ -942,6 +946,12 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
def CACHE_PATH(self):
return self.CONFIG_PATH / "cache"
@property
def PACKAGE_CACHE_PATH(self):
if self.PACKAGE_CACHE_ROOT and self.PACKAGE_CACHE_ROOT.strip():
return Path(self.PACKAGE_CACHE_ROOT).expanduser()
return self.CONFIG_PATH / ".cache"
@property
def ROOT_PATH(self):
return Path(__file__).parents[2]

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import os
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit, urlunsplit
PackageBackend = Literal["uv", "pip"]
@dataclass(frozen=True)
class PackageInstallRequest:
"""
Python 包安装请求,集中描述依赖文件、工具缓存、代理和本地 wheels 候选源。
"""
requirements_file: Path
python_bin: Path
find_links_dirs: list[Path] = field(default_factory=list)
constraints_file: Path | None = None
config_dir: Path = Path("/config")
package_cache_root: Path | None = None
pip_index_url: str | None = None
proxy_url: str | None = None
purpose: str = "plugin"
@dataclass(frozen=True)
class PackageInstallStrategy:
"""
单次安装尝试的完整执行信息,命令和日志展示命令分离以避免泄露凭据。
"""
strategy_name: str
backend: PackageBackend
command: list[str]
env: dict[str, str]
safe_log_command: list[str]
def redact_url(value: str) -> str:
"""
脱敏 URL 中的 userinfo保留 scheme、host、path、query 便于定位镜像源。
"""
parsed = urlsplit(value)
if "@" not in parsed.netloc:
return value
host = parsed.netloc.rsplit("@", 1)[-1]
return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
def redact_command(command: list[str]) -> list[str]:
"""
脱敏命令参数中的 URL 凭据,用于日志展示。
"""
return [redact_url(item) if "://" in item else item for item in command]
def build_package_install_env(request: PackageInstallRequest, include_moviepilot_proxy: bool = True) -> dict[str, str]:
"""
构造 pip/uv 安装子进程环境,默认把包下载缓存放到持久化配置目录。
"""
env = os.environ.copy()
config_dir = Path(request.config_dir)
if request.package_cache_root:
package_cache_root = Path(request.package_cache_root)
env["PACKAGE_CACHE_ROOT"] = str(package_cache_root)
else:
package_cache_root = Path(env.get("PACKAGE_CACHE_ROOT") or config_dir / ".cache")
env.setdefault("PACKAGE_CACHE_ROOT", str(package_cache_root))
env.setdefault("PIP_CACHE_DIR", str(package_cache_root / "pip"))
env.setdefault("UV_CACHE_DIR", str(package_cache_root / "uv"))
proxy = (request.proxy_url or "").strip()
if proxy and include_moviepilot_proxy:
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
env[key] = proxy
return env
def _find_uv(python_bin: Path) -> Path | None:
"""
优先使用解释器同目录 uv保证虚拟环境内 wrapper 与真实安装环境一致。
"""
uv_name = "uv.exe" if os.name == "nt" else "uv"
sibling = python_bin.with_name(uv_name)
if sibling.exists():
return sibling
found = shutil.which("uv")
return Path(found) if found else None
def _base_install_args(request: PackageInstallRequest) -> list[str]:
args: list[str] = []
for directory in request.find_links_dirs:
args.extend(["--find-links", str(directory)])
if request.constraints_file:
args.extend(["-c", str(request.constraints_file)])
args.extend(["-r", str(request.requirements_file)])
return args
def _network_variants(request: PackageInstallRequest) -> list[tuple[str, bool, bool]]:
has_index = bool((request.pip_index_url or "").strip())
has_proxy = bool((request.proxy_url or "").strip())
variants: list[tuple[str, bool, bool]] = []
if has_index and has_proxy:
variants.append(("镜像+代理", True, True))
if has_index:
variants.append(("镜像", True, False))
if has_proxy:
variants.append(("代理", False, True))
variants.append(("直连", False, False))
return variants
def _build_uv_command(uv_bin: Path, request: PackageInstallRequest, use_index: bool) -> list[str]:
command = [str(uv_bin), "pip", "install", "--python", str(request.python_bin)]
if use_index and request.pip_index_url:
command.extend(["--default-index", request.pip_index_url])
command.extend(_base_install_args(request))
return command
def _build_pip_command(request: PackageInstallRequest, use_index: bool) -> list[str]:
command = [str(request.python_bin), "-m", "pip", "install"]
if use_index and request.pip_index_url:
command.extend(["-i", request.pip_index_url])
command.extend(_base_install_args(request))
return command
def build_package_install_strategies(request: PackageInstallRequest) -> list[PackageInstallStrategy]:
"""
按 uv 优先、pip 兜底顺序构造网络降级策略。
"""
strategies: list[PackageInstallStrategy] = []
variants = _network_variants(request)
uv_bin = _find_uv(Path(request.python_bin))
if uv_bin:
for variant_name, use_index, use_proxy in variants:
command = _build_uv_command(uv_bin, request, use_index)
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
strategies.append(
PackageInstallStrategy(
strategy_name=f"uv:{variant_name}",
backend="uv",
command=command,
env=env,
safe_log_command=redact_command(command),
)
)
for variant_name, use_index, use_proxy in variants:
command = _build_pip_command(request, use_index)
env = build_package_install_env(request, include_moviepilot_proxy=use_proxy)
strategies.append(
PackageInstallStrategy(
strategy_name=f"pip:{variant_name}",
backend="pip",
command=command,
env=env,
safe_log_command=redact_command(command),
)
)
return strategies

View File

@@ -29,6 +29,7 @@ from requests import Response
from app.core.cache import cached, is_fresh
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.package_installer import PackageInstallRequest, build_package_install_strategies
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils, AsyncRequestUtils
@@ -1013,19 +1014,6 @@ class PluginHelper(metaclass=WeakSingleton):
# 去重并保持稳定顺序,避免重复传递相同目录
return list(dict.fromkeys(wheels_dirs))
@staticmethod
def __build_pip_install_strategies(base_cmd: List[str]) -> List[Tuple[str, List[str]]]:
"""
为 pip 命令构建统一的网络降级策略,避免不同安装路径各自拼接参数。
"""
strategies = []
if settings.PIP_PROXY:
strategies.append(("镜像站", base_cmd + ["-i", settings.PIP_PROXY]))
if settings.PROXY_HOST:
strategies.append(("代理", base_cmd + ["--proxy", settings.PROXY_HOST]))
strategies.append(("直连", base_cmd))
return strategies
@staticmethod
def __build_runtime_pip_command(*args: str) -> List[str]:
"""
@@ -1388,6 +1376,45 @@ class PluginHelper(metaclass=WeakSingleton):
importlib.reload(site)
importlib.invalidate_caches()
@classmethod
def __build_package_install_request(
cls,
requirements_file: Path,
find_links_dirs: Optional[List[Path]] = None,
constraints_file: Optional[Path] = None,
purpose: str = "plugin",
) -> PackageInstallRequest:
"""
将 MoviePilot 运行配置转换为 pip/uv 安装请求,统一缓存、镜像和代理语义。
"""
return PackageInstallRequest(
requirements_file=requirements_file,
python_bin=Path(sys.executable),
find_links_dirs=find_links_dirs or [],
constraints_file=constraints_file,
config_dir=settings.CONFIG_PATH,
package_cache_root=settings.PACKAGE_CACHE_PATH,
pip_index_url=settings.PIP_PROXY or None,
proxy_url=settings.PROXY_HOST or None,
purpose=purpose,
)
@classmethod
def __repair_if_runtime_broken(cls, snapshot_file: Optional[Path] = None) -> Tuple[bool, str]:
"""
安装失败后检查主运行环境;若已异常,先恢复主程序依赖再继续向上返回安装失败。
"""
health_ok, health_message = cls.__run_runtime_healthcheck()
if health_ok:
return True, ""
repair_ok, repair_message = cls.__repair_main_runtime_dependencies(snapshot_file)
if not repair_ok:
return False, f"插件依赖安装失败后主运行环境异常,且恢复失败:{health_message}; {repair_message}"
restored, restored_message = cls.__run_runtime_healthcheck()
if not restored:
return False, f"插件依赖安装失败后主运行环境异常,恢复后仍异常:{restored_message}"
return True, "主运行环境已恢复"
@classmethod
def __run_runtime_healthcheck(cls) -> Tuple[bool, str]:
"""
@@ -1420,15 +1447,19 @@ class PluginHelper(metaclass=WeakSingleton):
return False, f"恢复依赖文件不存在:{repair_target}"
last_error = ""
base_cmd = [sys.executable, "-m", "pip", "install", "-r", str(repair_target)]
for strategy_name, pip_command in cls.__build_pip_install_strategies(base_cmd):
logger.warning(f"[PIP] 运行环境异常,尝试使用策略:{strategy_name} 恢复{repair_desc}")
success, message = SystemUtils.execute_with_subprocess(pip_command)
request = cls.__build_package_install_request(repair_target, purpose="runtime-repair")
for strategy in build_package_install_strategies(request):
logger.warning(f"[PIP] 运行环境异常,尝试使用策略:{strategy.strategy_name} 恢复{repair_desc}")
success, message = SystemUtils.execute_with_subprocess(
strategy.command,
env=strategy.env,
safe_command=strategy.safe_log_command,
)
if success:
cls.__refresh_import_system()
return True, message
last_error = message
logger.error(f"[PIP] 使用策略:{strategy_name} 恢复{repair_desc}失败:{message}")
logger.error(f"[PIP] 使用策略:{strategy.strategy_name} 恢复{repair_desc}失败:{message}")
return False, last_error or f"恢复{repair_desc}失败"
@classmethod
@@ -1461,11 +1492,9 @@ class PluginHelper(metaclass=WeakSingleton):
seen_dirs.add(candidate_key)
resolved_dirs.append(candidate_path)
find_links_option = []
if resolved_dirs:
for local_wheels_dir in resolved_dirs:
logger.debug(f"[PIP] 发现可用的 wheels 目录: {local_wheels_dir},将优先从本地安装。")
find_links_option.extend(["--find-links", str(local_wheels_dir)])
else:
logger.debug(f"[PIP] 未发现可用的 wheels 目录,将仅使用在线源。")
@@ -1484,23 +1513,32 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"[PIP] 创建运行环境约束文件失败:{e}")
return False, f"创建运行环境约束文件失败:{e}"
base_cmd = [sys.executable, "-m", "pip", "install"] + find_links_option
if constraints_file:
# 这里固定约束到主程序依赖的当前版本,避免共享 venv 被插件改写核心运行环境。
base_cmd.extend(["-c", str(constraints_file)])
base_cmd.extend(["-r", str(requirements_file)])
strategies = cls.__build_pip_install_strategies(base_cmd)
request = cls.__build_package_install_request(
requirements_file,
find_links_dirs=resolved_dirs,
constraints_file=constraints_file,
purpose="plugin",
)
strategies = build_package_install_strategies(request)
try:
# pip 会修改当前解释器的 site-packages安装与缓存刷新必须串行避免运行态模块被并发安装窗口污染。
with cls._pip_install_lock:
loaded_modules_before_install = set(sys.modules.keys())
# 遍历策略进行安装
for strategy_name, pip_command in strategies:
logger.debug(f"[PIP] 尝试使用策略:{strategy_name} 安装依赖,命令:{' '.join(pip_command)}")
success, message = SystemUtils.execute_with_subprocess(pip_command)
last_error = ""
for strategy in strategies:
logger.debug(
f"[PIP] 尝试使用策略:{strategy.strategy_name} 安装依赖,"
f"命令:{' '.join(strategy.safe_log_command)}"
)
success, message = SystemUtils.execute_with_subprocess(
strategy.command,
env=strategy.env,
safe_command=strategy.safe_log_command,
)
if success:
logger.debug(f"[PIP] 策略:{strategy_name} 安装依赖成功,输出:{message}")
logger.debug(f"[PIP] 策略:{strategy.strategy_name} 安装依赖成功,输出:{message}")
health_ok, health_message = cls.__run_runtime_healthcheck()
if not health_ok:
logger.error(f"[PIP] 依赖安装后运行环境自检失败:{health_message}")
@@ -1532,11 +1570,22 @@ class PluginHelper(metaclass=WeakSingleton):
logger.debug(f"[PIP] 已刷新导入系统,新加载的模块: {loaded_modules_during_install}")
return True, message
logger.error(f"[PIP] 策略:{strategy_name} 安装依赖失败,错误信息:{message}")
last_error = message
repair_ok, repair_message = cls.__repair_if_runtime_broken(
constraints_file if protected_packages else None
)
logger.error(f"[PIP] 策略:{strategy.strategy_name} 安装依赖失败,错误信息:{message}")
if not repair_ok or repair_message:
return False, (
f"策略 {strategy.strategy_name} 安装依赖失败:{message}"
f"{repair_message}"
)
finally:
if constraints_file:
constraints_file.unlink(missing_ok=True)
if last_error:
return False, f"[PIP] 所有策略均安装依赖失败:{last_error}"
return False, "[PIP] 所有策略均安装依赖失败请检查网络连接、PIP 配置或插件依赖约束"
@staticmethod

View File

@@ -73,6 +73,24 @@ def clear_temp():
SystemUtils.clear(settings.TEMP_PATH, days=settings.TEMP_FILE_DAYS)
# 清理图片缓存目录中7天前的文件
SystemUtils.clear(settings.CACHE_PATH / "images", days=settings.GLOBAL_IMAGE_CACHE_DAYS)
# 清理 pip/uv 包下载缓存,不接管整个 .cache 目录。
clear_package_tool_cache()
def clear_package_tool_cache():
"""
清理 pip/uv 包下载缓存,只处理 MoviePilot 管理的工具子目录。
"""
days = settings.PACKAGE_CACHE_DAYS
if days <= 0:
return
tool_cache_root = settings.PACKAGE_CACHE_PATH
for child in ("pip", "uv"):
cache_path = tool_cache_root / child
try:
SystemUtils.clear(cache_path, days=days)
except Exception as err:
logger.warning("清理包下载缓存失败:%s - %s", cache_path, err)
def user_auth():

View File

@@ -6,6 +6,7 @@ import re
import shutil
import subprocess
import sys
import urllib.parse
import uuid
from pathlib import Path
from typing import List, Optional, Tuple, Union
@@ -20,6 +21,8 @@ class SystemUtils:
系统工具类,提供系统相关的操作和信息获取方法。
"""
_URL_WITH_USERINFO_PATTERN = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s]+)")
@staticmethod
def execute(cmd: str) -> str:
"""
@@ -33,22 +36,69 @@ class SystemUtils:
return ""
@staticmethod
def execute_with_subprocess(pip_command: list) -> Tuple[bool, str]:
def redact_url_userinfo(value: str) -> str:
"""
脱敏 URL 中的 userinfo避免命令输出泄露镜像源或代理凭据。
"""
def replace(match: re.Match[str]) -> str:
candidate = match.group(1)
trailing = ""
while candidate and candidate[-1] in ".,;:)":
trailing = candidate[-1] + trailing
candidate = candidate[:-1]
parsed = urllib.parse.urlsplit(candidate)
if not parsed.username and not parsed.password:
return match.group(1)
host = parsed.netloc.rsplit("@", 1)[-1]
redacted = urllib.parse.urlunsplit((
parsed.scheme,
host,
parsed.path,
parsed.query,
parsed.fragment,
))
return f"{redacted}{trailing}"
return SystemUtils._URL_WITH_USERINFO_PATTERN.sub(replace, value or "")
@staticmethod
def redact_command_url_userinfo(command: list[str]) -> List[str]:
"""
脱敏命令参数中的 URL userinfo供错误信息展示。
"""
return [SystemUtils.redact_url_userinfo(str(item)) for item in command]
@staticmethod
def execute_with_subprocess(
pip_command: list,
env: Optional[dict[str, str]] = None,
safe_command: Optional[list[str]] = None,
) -> Tuple[bool, str]:
"""
执行命令并捕获标准输出和错误输出,记录日志。
:param pip_command: 要执行的命令,以列表形式提供
:param env: 传递给子进程的环境变量
:param safe_command: 用于错误信息展示的脱敏命令
:return: (命令是否成功, 输出信息或错误信息)
"""
display_command = safe_command or pip_command
try:
# 使用 subprocess.run 捕获标准输出和标准错误
result = subprocess.run(pip_command, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
result = subprocess.run(
pip_command,
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# 合并 stdout 和 stderr
output = result.stdout + result.stderr
output = SystemUtils.redact_url_userinfo(result.stdout + result.stderr)
return True, output
except subprocess.CalledProcessError as e:
stdout = (e.stdout or "").strip()
stderr = (e.stderr or "").strip()
stdout = SystemUtils.redact_url_userinfo((e.stdout or "").strip())
stderr = SystemUtils.redact_url_userinfo((e.stderr or "").strip())
# 不同命令/兼容层可能把失败原因写入 stdout失败时需要同时保留两路输出。
output_parts = []
if stdout:
@@ -58,12 +108,15 @@ class SystemUtils:
if not output_parts:
output_parts.append("无标准输出或错误输出")
error_message = (
f"命令:{' '.join(pip_command)},执行失败,"
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},执行失败,"
f"返回码:{e.returncode}{'; '.join(output_parts)}"
)
return False, error_message
except Exception as e:
error_message = f"未知错误,命令:{' '.join(pip_command)},错误:{str(e)}"
error_message = (
f"未知错误,命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))}"
f"错误:{SystemUtils.redact_url_userinfo(str(e))}"
)
return False, error_message
@staticmethod