mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-08 00:01:27 +08:00
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:
13
app/cli.py
13
app/cli.py
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
169
app/helper/package_installer.py
Normal file
169
app/helper/package_installer.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user