Files
MoviePilot/app/adapters/external/market.py
T

3489 lines
148 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
from collections import deque
from dataclasses import dataclass
import importlib
import io
import json
import re
import shutil
import site
import stat
import sys
import tempfile
import threading
import time
import traceback
import uuid
import zipfile
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any, Dict, List, Optional, Tuple, Set, Callable, Awaitable, Iterator, Sequence
from urllib.parse import parse_qs, quote, unquote, urlparse, urlsplit
import aiofiles
import aioshutil
import httpx2
from anyio import Path as AsyncPath
from packaging.markers import default_environment
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from packaging.utils import canonicalize_name
from packaging.version import Version, InvalidVersion
from importlib.metadata import distributions
from requests import Response
from app.runtime.cache import cached, is_fresh
from app.runtime.dependencies import (
iter_runtime_profile_requirement_strings,
iter_runtime_requirement_strings,
runtime_excluded_dependency_pairs,
)
from app.runtime.settings import get_runtime_setting
from app.adapters.system.package import (
PackageInstallRequest,
build_package_install_strategies,
build_project_sync_strategies,
find_uv,
)
from app.adapters.system.plugin.manifest import (
PluginDependencyManifestError,
load_dependency_file,
load_dependency_manifest,
)
from app.runtime.log import logger
from app.runtime.observability import observe_compat_facade
from app.runtime.execution import (
await_task_to_terminal,
run_in_threadpool_to_completion as _await_thread_operation,
)
from app.runtime.tasks import get_task_registry
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
from app.foundation.singleton import WeakSingleton
from app.foundation.version import compare_version
from app.adapters.system.host import SystemUtils
from app.foundation.url import UrlUtils
from app.runtime.version import get_app_version
# 插件市场只通过 runtime 读取端口消费组合根的最新配置。
PLUGIN_DIR = Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins"
LOCAL_REPO_PREFIX = "local://"
PLUGIN_SYSTEM_VERSION_FIELD = "system_version"
PLUGIN_MARKET_WIKI_START = "<!-- plugin-market-repos:start -->"
PLUGIN_MARKET_WIKI_END = "<!-- plugin-market-repos:end -->"
PLUGIN_MARKET_WIKI_URL = (
"https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
)
PLUGIN_MARKET_REPO_PATTERN = re.compile(
r"https?://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?/?",
re.IGNORECASE,
)
# 主程序重大版本可扫描的旧索引;V3 临时默认兼容 V2,条目可用 v3:false 排除。
VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = {
"v3": ["v2"],
}
InstalledPluginsProvider = Callable[[], List[str]]
PluginInstallGateway = Callable[
[str, str, Optional[str], Optional[str], bool],
Tuple[bool, str],
]
AsyncPluginInstallGateway = Callable[
[str, str, Optional[str], Optional[str], bool],
Awaitable[Tuple[bool, str]],
]
@dataclass(frozen=True)
class _RemotePluginInstallPlan:
"""描述远端插件内容准备模式,不持有同步或异步 I/O 实现。"""
release_tag: Optional[str]
fallback_to_filelist: bool
def _empty_installed_plugins() -> List[str]:
"""组合根尚未注入配置读取器时返回空安装清单。"""
return []
_installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins
def _unconfigured_plugin_install_gateway(
_pid: str,
_repo_url: str,
_package_version: Optional[str],
_release_version: Optional[str],
_force_install: bool,
) -> Tuple[bool, str]:
"""在组合根尚未装配来源门禁时拒绝插件包写入。"""
return False, "插件安装服务尚未完成初始化"
async def _unconfigured_async_plugin_install_gateway(
_pid: str,
_repo_url: str,
_package_version: Optional[str],
_release_version: Optional[str],
_force_install: bool,
) -> Tuple[bool, str]:
"""在组合根尚未装配来源门禁时拒绝异步插件包写入。"""
return False, "插件安装服务尚未完成初始化"
_plugin_install_gateway: PluginInstallGateway = _unconfigured_plugin_install_gateway
_async_plugin_install_gateway: AsyncPluginInstallGateway = (
_unconfigured_async_plugin_install_gateway
)
def configure_installed_plugins_provider(
provider: InstalledPluginsProvider,
) -> None:
"""由启动组合层注入已安装插件读取器,避免市场适配器访问数据库。"""
global _installed_plugins_provider
_installed_plugins_provider = provider
def configure_plugin_install_gateway(
*,
install: PluginInstallGateway,
async_install: AsyncPluginInstallGateway,
) -> None:
"""由启动组合根装配公开兼容安装入口的来源门禁。"""
global _plugin_install_gateway, _async_plugin_install_gateway
_plugin_install_gateway = install
_async_plugin_install_gateway = async_install
def reset_plugin_install_gateway() -> None:
"""恢复未装配状态,供隔离测试清理进程级安装入口。"""
global _plugin_install_gateway, _async_plugin_install_gateway
_plugin_install_gateway = _unconfigured_plugin_install_gateway
_async_plugin_install_gateway = _unconfigured_async_plugin_install_gateway
def normalize_plugin_market_repo_url(repo_url: str) -> Optional[str]:
"""规范化插件仓库地址,便于跨来源合并去重。"""
repo_url = (repo_url or "").strip().rstrip("/")
if not repo_url:
return None
repo_url = repo_url.removesuffix(".git")
parsed_url = urlparse(repo_url)
if parsed_url.scheme not in {"http", "https"}:
return None
if (parsed_url.hostname or "").lower() != "github.com":
return None
paths = [item for item in parsed_url.path.split("/") if item]
if len(paths) < 2:
return None
return f"https://github.com/{paths[0]}/{paths[1]}"
def split_plugin_market_repo_urls(value: Optional[str]) -> list[str]:
"""拆分插件市场仓库配置并保持原有顺序去重。"""
repos: list[str] = []
seen_repos = set()
for item in re.split(r"[\n,]+", value or ""):
normalized_repo = normalize_plugin_market_repo_url(item)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return repos
def extract_plugin_market_repos_from_wiki(
markdown: str, require_markers: bool = False
) -> list[str]:
"""
从 Wiki 插件文档中提取插件仓库地址。
:param markdown: Wiki 插件文档 Markdown 内容
:param require_markers: 是否要求文档包含唯一且有序的清单边界标记
:return: 规范化并按文档顺序去重的插件仓库地址
"""
content = markdown or ""
start_count = content.count(PLUGIN_MARKET_WIKI_START)
end_count = content.count(PLUGIN_MARKET_WIKI_END)
start_index = content.find(PLUGIN_MARKET_WIKI_START)
end_index = content.find(PLUGIN_MARKET_WIKI_END)
if start_count == 1 and end_count == 1 and start_index < end_index:
content = content[
start_index + len(PLUGIN_MARKET_WIKI_START):end_index
]
elif require_markers:
raise ValueError("Wiki 插件仓库清单必须包含唯一且有序的开始和结束标记")
repos: list[str] = []
seen_repos = set()
for item in PLUGIN_MARKET_REPO_PATTERN.findall(content):
normalized_repo = normalize_plugin_market_repo_url(item)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return repos
def merge_plugin_market_repos(
local_repos: list[str], wiki_repos: list[str]
) -> list[str]:
"""合并本地与 Wiki 插件仓库地址,并保持来源中的既有顺序。"""
merged_repos: list[str] = []
seen_repos = set()
for repo in local_repos + wiki_repos:
normalized_repo = normalize_plugin_market_repo_url(repo)
if not normalized_repo or normalized_repo.lower() in seen_repos:
continue
merged_repos.append(normalized_repo)
seen_repos.add(normalized_repo.lower())
return merged_repos
@observe_compat_facade("PluginHelper")
class PluginHelper(metaclass=WeakSingleton):
"""
插件市场管理,下载安装插件到本地
"""
_base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/"
# 串行化运行期依赖安装,避免多个包安装子进程和导入缓存刷新互相踩踏。
_package_install_lock = threading.Lock()
PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 300
# 同仓库的并发 Release 请求共享任务;事件循环参与键控,避免热重载或测试循环切换后复用失效任务。
_release_task_lock = threading.Lock()
_release_tasks: Dict[Tuple[asyncio.AbstractEventLoop, str, bool], asyncio.Task] = {}
# 这些包一旦被插件覆盖,最容易直接拖垮主程序启动,因此冲突提示需要单独高亮。
_protected_runtime_packages = frozenset({
"alembic",
"fastapi",
"pydantic",
"pydantic_core",
"pydantic_settings",
"sqlalchemy",
"starlette",
"uvicorn",
})
_runtime_import_probe = "app.doctor.dependencies"
@staticmethod
def is_local_repo_url(repo_url: Optional[str]) -> bool:
"""
判断是否为本地插件来源标识
"""
return bool(repo_url and repo_url.startswith(LOCAL_REPO_PREFIX))
@staticmethod
def make_local_repo_url(pid: str, repo_path: Optional[Path] = None,
package_version: Optional[str] = None) -> str:
"""
生成本地插件安装来源标识
"""
repo_url = f"{LOCAL_REPO_PREFIX}{quote(pid, safe='')}"
params = []
if repo_path:
params.append(f"path={quote(str(repo_path), safe='/:~')}")
if package_version:
params.append(f"version={quote(package_version, safe='')}")
if params:
repo_url = f"{repo_url}?{'&'.join(params)}"
return repo_url
@staticmethod
def parse_local_repo_url(repo_url: str) -> Optional[str]:
"""
从本地插件来源标识中解析插件ID
"""
if not PluginHelper.is_local_repo_url(repo_url):
return None
try:
parts = urlsplit(repo_url)
pid = unquote(parts.netloc or parts.path.strip("/"))
except Exception:
pid = repo_url[len(LOCAL_REPO_PREFIX):].split("?", 1)[0].strip("/")
return pid or None
@staticmethod
def parse_local_repo_path(repo_url: str) -> Optional[Path]:
"""
从本地插件来源标识中解析仓库路径
"""
if not PluginHelper.is_local_repo_url(repo_url):
return None
try:
values = parse_qs(urlsplit(repo_url).query).get("path")
if not values:
return None
path = Path(values[0]).expanduser()
if not path.is_absolute():
path = get_runtime_setting('ROOT_PATH') / path
return path.resolve()
except Exception:
return None
@staticmethod
def parse_local_repo_package_version(repo_url: str) -> Optional[str]:
"""
从本地插件来源标识中解析 package 版本
"""
if not PluginHelper.is_local_repo_url(repo_url):
return None
try:
values = parse_qs(urlsplit(repo_url).query).get("version")
if not values:
return None
return values[0]
except Exception:
return None
@staticmethod
def get_current_system_version() -> Optional[Version]:
"""
解析当前主程序版本,供插件 package 中的系统版本范围匹配使用。
"""
try:
return Version(get_app_version())
except InvalidVersion:
logger.error(f"当前主程序版本号无法解析:{get_app_version()}")
return None
@classmethod
def get_compatible_version_flags(cls) -> List[str]:
"""
返回当前主程序版本可兼容的全部版本标识,包含自身及向后兼容的低版本,按优先级降序。
未启用 VERSION_FLAG(v1)时返回空列表,表示仅使用 package.json 基础索引。
"""
flags: List[str] = []
if get_runtime_setting('VERSION_FLAG'):
flags.append(get_runtime_setting('VERSION_FLAG'))
flags.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), []))
return flags
@classmethod
def is_plugin_info_compatible(cls, plugin_info: Optional[dict]) -> bool:
"""
判断 package.json 中的插件元数据是否兼容当前主程序版本。
默认索引需要声明当前版本;V3 临时兼容已声明 V2 的共享实现,
但显式 ``v3: false`` 始终优先拒绝。
"""
if not isinstance(plugin_info, dict):
return False
if not get_runtime_setting('VERSION_FLAG'):
return True
current_flag = get_runtime_setting('VERSION_FLAG')
if plugin_info.get(current_flag) is False:
return False
if plugin_info.get(current_flag) is True:
return True
return any(
plugin_info.get(flag) is True
for flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(
current_flag, []
)
)
@classmethod
def is_package_plugin_compatible(
cls,
plugin_info: Optional[dict],
package_version: Optional[str],
) -> bool:
"""
判断指定索引中的插件条目能否在当前主程序版本使用。
当前代专用索引直接兼容。V3 临时默认兼容 V2 专用索引,
除非条目显式声明 ``v3: false``;默认索引仍需先声明 ``v2: true``。
"""
if not isinstance(plugin_info, dict):
return False
current_flag = get_runtime_setting('VERSION_FLAG')
if not current_flag:
return not package_version
if package_version == current_flag:
return True
if package_version in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(
current_flag, []
):
return plugin_info.get(current_flag) is not False
if not package_version:
return cls.is_plugin_info_compatible(plugin_info)
return False
@staticmethod
def _package_version_candidates(
package_version: Optional[str],
) -> Tuple[str, ...]:
"""返回插件安装唯一的代际候选顺序,并去除重复的基础索引。"""
preferred_version = package_version or get_runtime_setting('VERSION_FLAG')
candidates = [preferred_version]
candidates.extend(
VERSION_BACKWARD_COMPATIBLE_FLAGS.get(preferred_version, [])
)
candidates.append("")
return tuple(dict.fromkeys(candidates))
@classmethod
def _select_compatible_package_version(
cls,
pid: str,
package_version: str,
plugins: Optional[Dict[str, dict]],
) -> Optional[str]:
"""从一个索引结果选择目标插件,并复用统一的代际兼容判定。"""
plugin = (plugins or {}).get(pid)
if plugin and cls.is_package_plugin_compatible(plugin, package_version):
return package_version
return None
@classmethod
def check_plugin_system_version(cls, plugin_info: Optional[dict]) -> Tuple[bool, str]:
"""
检查插件 package 元数据中的主系统版本范围是否满足当前 MoviePilot 版本。
"""
if not isinstance(plugin_info, dict):
return True, ""
raw_specifier = plugin_info.get(PLUGIN_SYSTEM_VERSION_FIELD)
if raw_specifier is None or raw_specifier == "":
return True, ""
if not isinstance(raw_specifier, str):
return False, (
f"插件限定的系统版本范围 {PLUGIN_SYSTEM_VERSION_FIELD} 必须是字符串,"
f"请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3"
)
system_version = cls.get_current_system_version()
if system_version is None:
return False, f"当前 MoviePilot 版本 {get_app_version()} 无法解析,已拒绝安装带版本限制的插件"
try:
specifier_set = SpecifierSet(raw_specifier)
except InvalidSpecifier:
return False, (
f"插件限定的系统版本范围格式不正确:{raw_specifier}"
f"请使用 PEP 440 版本范围格式,例如 >=2.12.0,<3"
)
if specifier_set.contains(system_version, prereleases=True):
return True, ""
return False, (
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装"
)
@classmethod
def annotate_plugin_system_version(cls, plugin_info: dict) -> dict:
"""
为插件 package 元数据补充系统版本兼容状态,便于市场展示和安装流程复用。
"""
if not isinstance(plugin_info, dict):
return plugin_info
compatible, message = cls.check_plugin_system_version(plugin_info)
plugin_info["system_version_compatible"] = compatible
plugin_info["system_version_message"] = message
return plugin_info
@staticmethod
def get_local_repo_paths() -> List[Path]:
"""
获取本地插件仓库目录列表
"""
if not get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS'):
return []
paths = []
for item in get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS').split(","):
local_repo_path = item.strip()
if not local_repo_path:
continue
path = Path(local_repo_path).expanduser()
if not path.is_absolute():
path = get_runtime_setting('ROOT_PATH') / path
paths.append(path.resolve())
return paths
@staticmethod
def __get_local_package(repo_path: Path, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
"""
从本地插件仓库读取 package.json 或 package.{version}.json
"""
package_file = repo_path / (
f"package.{package_version}.json" if package_version else "package.json"
)
if not package_file.exists():
return {}
try:
content = package_file.read_text(encoding="utf-8", errors="replace")
payload = json.loads(content)
except Exception as e:
logger.warn(f"读取本地插件包 {package_file} 失败:{e}")
return None
if not isinstance(payload, dict):
logger.warn(f"本地插件包 {package_file} 格式不正确")
return None
return payload
@staticmethod
def __get_local_plugin_dir(repo_path: Path, pid: str, package_version: Optional[str]) -> Path:
"""按插件包版本计算本地插件源码目录。"""
plugin_root = f"plugins.{package_version}" if package_version else "plugins"
return repo_path / plugin_root / pid.lower()
def get_local_plugin_candidates(self) -> Dict[str, dict]:
"""
扫描本地插件仓库,按插件ID保留版本号最高的候选
"""
candidates: Dict[str, dict] = {}
for repo_order, repo_path in enumerate(self.get_local_repo_paths()):
if not repo_path.exists() or not repo_path.is_dir():
logger.warn(f"本地插件仓库目录不存在或不可读:{repo_path}")
continue
package_candidates = []
if get_runtime_setting('VERSION_FLAG'):
package_candidates.append((get_runtime_setting('VERSION_FLAG'), self.__get_local_package(repo_path,
get_runtime_setting('VERSION_FLAG'))))
# 向后兼容:补充扫描更低版本的 package 文件,便于本地仓库复用历史版本插件。
for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), []):
package_candidates.append((backward_flag, self.__get_local_package(repo_path, backward_flag)))
package_candidates.append(("", self.__get_local_package(repo_path)))
for package_version, local_plugins in package_candidates:
if local_plugins is None:
continue
for pid, plugin_info in local_plugins.items():
if not isinstance(plugin_info, dict):
continue
if not self.is_package_plugin_compatible(
plugin_info, package_version
):
continue
plugin_dir = self.__get_local_plugin_dir(repo_path, pid, package_version)
if not plugin_dir.is_dir():
logger.debug(f"跳过本地插件 {pid}:插件目录不存在 {plugin_dir}")
continue
candidate = plugin_info.copy()
candidate["id"] = pid
candidate["package_version"] = package_version
candidate["repo_order"] = repo_order
candidate["repo_path"] = repo_path
candidate["path"] = plugin_dir
candidate["repo_url"] = self.make_local_repo_url(
pid,
repo_path,
package_version or None,
)
self.annotate_plugin_system_version(candidate)
candidate_version = str(candidate.get("version") or "0")
existing = candidates.get(pid)
if not existing:
candidates[pid] = candidate
continue
existing_version = str(existing.get("version") or "0")
if compare_version(candidate_version, ">", existing_version):
candidates[pid] = candidate
elif (
candidate_version == existing_version
and repo_order < int(existing.get("repo_order", repo_order))
):
logger.info(f"本地插件 {pid} 存在同版本来源,使用靠前目录:{repo_path}")
candidates[pid] = candidate
return candidates
def get_local_plugin_candidate(self, pid: str, package_version: Optional[str] = None,
repo_path: Optional[Path] = None,
strict_compat: bool = True,
strict_system_version: bool = True) -> Optional[dict]:
"""
获取指定插件ID的本地插件候选
:param strict_system_version: 是否将主系统版本范围不匹配视为不可用候选
"""
if not pid:
return None
if package_version is not None or repo_path is not None:
repo_paths = [repo_path.resolve()] if repo_path else self.get_local_repo_paths()
package_versions = [package_version] if package_version is not None else []
if package_version is None:
if get_runtime_setting('VERSION_FLAG'):
package_versions.append(get_runtime_setting('VERSION_FLAG'))
package_versions.extend(VERSION_BACKWARD_COMPATIBLE_FLAGS.get(get_runtime_setting('VERSION_FLAG'), []))
package_versions.append("")
selected_candidate = None
for repo_order, local_repo_path in enumerate(self.get_local_repo_paths()):
if local_repo_path not in repo_paths:
continue
for current_package_version in package_versions:
local_plugins = self.__get_local_package(local_repo_path, current_package_version or "")
if not local_plugins:
continue
for candidate_pid, plugin_info in local_plugins.items():
if candidate_pid.lower() != pid.lower() or not isinstance(plugin_info, dict):
continue
is_compatible = self.is_package_plugin_compatible(
plugin_info,
current_package_version or "",
)
if not is_compatible and strict_compat:
continue
plugin_dir = self.__get_local_plugin_dir(local_repo_path, candidate_pid,
current_package_version or "")
if not plugin_dir.is_dir():
continue
candidate = plugin_info.copy()
candidate["id"] = candidate_pid
candidate["package_version"] = current_package_version or ""
candidate["repo_order"] = repo_order
candidate["repo_path"] = local_repo_path
candidate["path"] = plugin_dir
candidate["repo_url"] = self.make_local_repo_url(
candidate_pid,
local_repo_path,
current_package_version or None,
)
if not is_compatible:
candidate["compatible"] = False
candidate["skip_reason"] = (
f"插件索引条目不兼容 {get_runtime_setting('VERSION_FLAG')}"
)
self.annotate_plugin_system_version(candidate)
if strict_system_version and candidate.get("system_version_compatible") is False:
candidate["compatible"] = False
candidate["skip_reason"] = candidate.get("system_version_message")
if package_version is not None:
return candidate
if not selected_candidate:
selected_candidate = candidate
continue
selected_version = str(selected_candidate.get("version") or "0")
candidate_version = str(candidate.get("version") or "0")
if compare_version(candidate_version, ">", selected_version):
selected_candidate = candidate
return selected_candidate
candidates = self.get_local_plugin_candidates()
for candidate_pid, candidate in candidates.items():
if candidate_pid.lower() == pid.lower():
if strict_system_version and candidate.get("system_version_compatible") is False:
candidate = candidate.copy()
candidate["compatible"] = False
candidate["skip_reason"] = candidate.get("system_version_message")
return candidate
return None
@staticmethod
def __append_cache_buster(url: str) -> str:
"""
强制刷新插件库索引时追加时间戳,绕过 GitHub 镜像或中间代理的缓存。
"""
if not is_fresh():
return url
parts = urlsplit(url)
refresh_param = f"_refresh={time.time_ns()}"
query = f"{parts.query}&{refresh_param}" if parts.query else refresh_param
return parts._replace(query=query).geturl()
@staticmethod
def __parse_plugin_index_response(content: str) -> Optional[Dict[str, dict]]:
"""
解析插件索引响应,仅缓存成功解析出的字典结果。
"""
try:
payload = json.loads(content)
except json.JSONDecodeError:
if "404: Not Found" not in content:
logger.warn(f"插件包数据解析失败:{content}")
return None
if not isinstance(payload, dict):
logger.warn(f"插件包数据格式不正确,期望 dict,实际为 {type(payload).__name__}")
return None
return payload
@classmethod
def _build_plugin_index_request(
cls,
repo_url: str,
package_version: Optional[str] = None,
) -> Optional[Tuple[str, dict]]:
"""构造插件索引请求,统一仓库解析、代际文件名和鉴权请求头。"""
if not repo_url:
return None
user, repo = cls.get_repo_info(repo_url)
if not user or not repo:
return None
raw_url = cls._base_url.format(user=user, repo=repo)
package_file = (
f"package.{package_version}.json"
if package_version
else "package.json"
)
package_url = cls.__append_cache_buster(f"{raw_url}{package_file}")
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=f"{user}/{repo}")
return package_url, headers
@classmethod
def _resolve_plugin_index_response(
cls,
status_code: int,
content: str,
) -> Optional[Dict[str, dict]]:
"""统一解释插件索引 HTTP 响应,保留不存在、失败和有效索引三态。"""
if status_code == 404:
return {}
if status_code != 200:
return None
return cls.__parse_plugin_index_response(content)
@staticmethod
def __build_plugin_release_item(pid: str, release_info: dict) -> Optional[dict]:
"""
从 GitHub release 响应中提取可安装版本,仅接受规范 tag 与同名 zip 资产。
"""
if not isinstance(release_info, dict):
return None
tag_name = release_info.get("tag_name")
if not isinstance(tag_name, str):
return None
tag_prefix = f"{pid}_v"
if not tag_name.startswith(tag_prefix):
return None
version = tag_name[len(tag_prefix):]
if not version:
return None
asset_name = f"{tag_name.lower()}.zip"
assets = release_info.get("assets") or []
if not any(isinstance(asset, dict) and asset.get("name") == asset_name for asset in assets):
return None
return {
"version": version,
"tag_name": tag_name,
"name": release_info.get("name") or tag_name,
"published_at": release_info.get("published_at"),
"body": release_info.get("body") or "",
"asset_name": asset_name,
}
@staticmethod
def __parse_plugin_release_response(pid: str, payload) -> List[dict]:
"""
解析 GitHub release 列表,过滤出当前插件可直接安装的 release 资产。
"""
if not isinstance(payload, list):
return []
releases = []
for release_info in payload:
item = PluginHelper.__build_plugin_release_item(pid, release_info)
if item:
releases.append(item)
return releases
@staticmethod
def __normalize_plugin_release_response(payload) -> List[dict]:
"""仅保留版本展示和资产匹配所需字段,控制仓库级缓存体积。"""
if not isinstance(payload, list):
return []
return [
{
"tag_name": release_info.get("tag_name"),
"name": release_info.get("name"),
"published_at": release_info.get("published_at"),
"body": release_info.get("body"),
"assets": [
{"name": asset.get("name")}
for asset in release_info.get("assets") or []
if isinstance(asset, dict)
],
}
for release_info in payload
if isinstance(release_info, dict)
]
@classmethod
def _iter_plugin_release_page_requests(
cls,
repo_url: str,
) -> Iterator[Tuple[str, dict]]:
"""按需生成仓库 Release 分页请求,统一仓库解析、请求头和页数上限。"""
if not repo_url:
return
user, repo = cls.get_repo_info(repo_url)
if not user or not repo:
return
user_repo = f"{user}/{repo}"
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo)
for page in range(1, 11):
release_api = (
f"https://api.github.com/repos/{user_repo}/releases"
f"?per_page=100&page={page}"
)
yield cls.__append_cache_buster(release_api), headers
@classmethod
def _merge_plugin_release_page(
cls,
repo_url: str,
response,
releases: List[dict],
) -> Optional[bool]:
"""合并一页 Release 响应;返回真继续、假结束,None 表示整次读取失败。"""
if response is None or response.status_code != 200:
return None
try:
payload = response.json()
except Exception as error:
logger.error(f"解析插件仓库 {repo_url} Release 列表失败:{error}")
return None
if not payload:
return False
if not isinstance(payload, list):
return None
releases.extend(cls.__normalize_plugin_release_response(payload))
return len(payload) >= 100
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
def get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
) -> Optional[Dict[str, Dict[str, Any]]]:
"""读取插件索引;404 返回 None,读取失败由调用方记录。"""
request = self._build_plugin_index_request(repo_url, package_version)
if request is None:
raise ValueError("插件仓库地址无效")
package_url, headers = request
res = self.__request_with_fallback(package_url, headers=headers)
if res is None:
raise RuntimeError("插件索引请求失败:连接失败")
if res.status_code == 404:
return None
if res.status_code != 200:
raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}")
payload = self.__parse_plugin_index_response(res.text)
if payload is None:
raise RuntimeError("插件索引响应格式无效")
return payload
def get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
"""
获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
:param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
"""
try:
payload = self.get_plugin_index_result(repo_url, package_version)
except (ValueError, RuntimeError):
return None
return payload if payload is not None else {}
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases")
def _get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
"""
按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。
"""
releases = []
for release_api, headers in self._iter_plugin_release_page_requests(
repo_url
):
res = self.__request_with_fallback(
release_api,
headers=headers,
timeout=30,
is_api=True,
)
should_continue = self._merge_plugin_release_page(
repo_url,
res,
releases,
)
if should_continue is None:
return None
if not should_continue:
break
return releases
def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
"""
获取插件可安装的 GitHub Release 版本列表。
GitHub 分页结果按仓库缓存,插件 ID 只参与本地过滤,避免同仓库重复分页。
"""
if not pid or not repo_url:
return []
return self.__parse_plugin_release_response(pid, self._get_plugin_repo_releases(repo_url.rstrip("/")))
@staticmethod
def __has_installable_release_version(
release_items: Sequence[dict],
release_version: str,
) -> bool:
"""
指定版本必须来自已解析出的可安装 Release 列表,避免直接拼接任意 tag。
"""
return any(item.get("version") == release_version for item in release_items)
@classmethod
def _build_remote_plugin_install_plan(
cls,
pid: str,
meta: dict,
release_version: Optional[str] = None,
release_items: Sequence[dict] = (),
) -> Tuple[Optional[_RemotePluginInstallPlan], str]:
"""统一选择指定 Release、可回退当前 Release 或文件列表安装模式。"""
is_release = meta.get("release")
plugin_version = meta.get("version")
if release_version:
if not is_release:
return None, f"{pid} 未声明 Release 安装,无法安装指定版本"
if not cls.__has_installable_release_version(
release_items, release_version
):
return None, f"{pid} 未找到可安装的 Release 版本:{release_version}"
if release_version == plugin_version:
compatible, message = cls.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return None, message
return _RemotePluginInstallPlan(
release_tag=f"{pid}_v{release_version}",
fallback_to_filelist=False,
), ""
compatible, message = cls.check_plugin_system_version(meta)
if not compatible:
logger.debug(f"{pid} 插件系统版本兼容性检查失败:{message}")
return None, message
if not is_release:
return _RemotePluginInstallPlan(
release_tag=None,
fallback_to_filelist=False,
), ""
if not plugin_version:
return None, f"未在插件清单中找到 {pid} 的版本号,无法进行 Release 安装"
return _RemotePluginInstallPlan(
release_tag=f"{pid}_v{plugin_version}",
fallback_to_filelist=True,
), ""
def get_plugin_package_version(self, pid: str, repo_url: str,
package_version: Optional[str] = None) -> Optional[str]:
"""
检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测
1. 如果未指定版本,则使用系统配置的默认版本(通过 get_runtime_setting('VERSION_FLAG') 设置)
2. 优先检查指定版本的插件(如 `package.v2.json`
3. 检查更低版本的 package 文件,并应用版本兼容标志
4. 检查 `package.json` 文件,并应用共享实现兼容标志
5. 如果插件不存在或不兼容指定版本,返回 `None`
:param pid: 插件 ID,用于在插件列表中查找
:param repo_url: 插件仓库的 URL,指定用于获取插件信息的 GitHub 仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
:return: 返回可用的插件版本号 (如 "v2",如果指定版本不可用则返回空字符串表示 v1),如果插件不可用则返回 None
"""
for candidate in self._package_version_candidates(package_version):
selected = self._select_compatible_package_version(
pid=pid,
package_version=candidate,
plugins=self.get_plugins(repo_url, candidate or None),
)
if selected is not None:
return selected
# 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用
return None
@staticmethod
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
"""
获取GitHub仓库信息
"""
if not repo_url:
return None, None
if not repo_url.endswith("/"):
repo_url += "/"
if repo_url.count("/") < 6:
repo_url = f"{repo_url}main/"
try:
user, repo = repo_url.split("/")[-4:-2]
except Exception as e:
logger.error(f"解析GitHub仓库地址失败:{str(e)} - {traceback.format_exc()}")
return None, None
return user, repo
def install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
release_version: Optional[str] = None, force_install: bool = False) \
-> Tuple[bool, str]:
"""
通过宿主统一 Gateway 安装插件,保留第三方插件使用的同步兼容 API。
:param pid: 插件 ID
:param repo_url: 插件仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
:param force_install: 是否替换已存在的插件载荷
:return: (是否成功, 错误信息)
"""
return _plugin_install_gateway(
pid,
repo_url,
package_version,
release_version,
force_install,
)
def __install_package(self, pid: str, repo_url: str, package_version: Optional[str] = None,
release_version: Optional[str] = None, force_install: bool = False) \
-> Tuple[bool, str]:
"""执行已通过来源准入的同步包安装,不负责身份或运行态提交。"""
if self.is_local_repo_url(repo_url):
return self.__install_local_package(
pid=pid,
repo_url=repo_url,
force_install=force_install,
)
if SystemUtils.is_frozen():
return False, "可执行文件模式下,只能安装本地插件"
# 验证参数
if not pid or not repo_url:
return False, "参数错误"
# 从 GitHub 的 repo_url 获取用户和项目名
user, repo = self.get_repo_info(repo_url)
if not user or not repo:
return False, "不支持的插件仓库地址格式"
user_repo = f"{user}/{repo}"
if not package_version:
package_version = get_runtime_setting('VERSION_FLAG')
# 1. 优先检查指定版本的插件
package_version = self.get_plugin_package_version(pid, repo_url, package_version)
# 如果 package_version 为None,说明没有找到匹配的插件
if package_version is None:
msg = f"{pid} 没有找到适用于当前版本的插件"
logger.debug(msg)
return False, msg
# package_version 为空,表示从 package.json 中找到插件
elif package_version == "":
logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件")
else:
logger.debug(f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件")
# 2. 决定安装方式(release 或文件列表)并执行统一安装流程。
meta = self.__get_plugin_meta(pid, repo_url, package_version)
release_items = (
self.get_plugin_release_versions(pid, repo_url)
if release_version
else []
)
plan, message = self._build_remote_plugin_install_plan(
pid=pid,
meta=meta,
release_version=release_version,
release_items=release_items,
)
if plan is None:
return False, message
release_tag = plan.release_tag
if release_tag and not plan.fallback_to_filelist:
def prepare_selected_release() -> Tuple[bool, str]:
return self.__install_from_release(
pid,
user_repo,
release_tag,
)
return self.__install_flow_sync(pid, force_install, prepare_selected_release, repo_url)
if release_tag:
# 当前索引 Release 失败时回退文件列表,避免发布产物短暂滞后阻断安装。
def prepare_release() -> Tuple[bool, str]:
ok, msg = self.__install_from_release(
pid,
user_repo,
release_tag,
)
if ok:
return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
self.__remove_old_plugin(pid)
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_release, repo_url)
# 未声明 release 打包的插件继续使用文件列表方式安装。
def prepare_filelist() -> Tuple[bool, str]:
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
def install_local(self, pid: str, repo_url: str = "", force_install: bool = False) -> Tuple[bool, str]:
"""通过宿主统一 Gateway 安装本地插件。"""
target_repo = repo_url or self.make_local_repo_url(pid)
return _plugin_install_gateway(
pid,
target_repo,
self.parse_local_repo_package_version(target_repo),
None,
force_install,
)
def __install_local_package(
self,
pid: str,
repo_url: str = "",
force_install: bool = False,
) -> Tuple[bool, str]:
"""
执行已通过来源准入的本地插件包安装。
"""
local_pid = self.parse_local_repo_url(repo_url) if repo_url else pid
if not local_pid or local_pid.lower() != pid.lower():
return False, "本地插件来源与插件ID不匹配"
repo_path = self.parse_local_repo_path(repo_url) if repo_url else None
package_version = self.parse_local_repo_package_version(repo_url) if repo_url else None
candidate = self.get_local_plugin_candidate(
pid,
package_version=package_version,
repo_path=repo_path
)
if not candidate:
return False, f"未找到本地插件:{pid}"
compatible, message = self.check_plugin_system_version(candidate)
if not compatible:
logger.debug(f"{pid} 本地插件系统版本兼容性检查失败:{message}")
return False, message
source_dir = Path(candidate.get("path"))
dest_dir = PLUGIN_DIR / pid.lower()
try:
if source_dir.resolve() == dest_dir.resolve():
return False, "本地插件来源不能与运行目录相同"
except Exception:
return False, "本地插件来源路径无效"
def prepare_local() -> Tuple[bool, str]:
try:
shutil.copytree(
source_dir,
dest_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "node_modules")
)
return True, ""
except Exception as e:
logger.error(f"复制本地插件 {pid} 失败:{e}")
return False, f"复制本地插件失败:{e}"
return self.__install_flow_sync(
pid=pid,
force_install=force_install,
prepare_content=prepare_local,
repo_url=repo_url or self.make_local_repo_url(
pid,
candidate.get("repo_path"),
candidate.get("package_version")
)
)
def __get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
Tuple[Optional[list], Optional[str]]:
"""
获取插件的文件列表
:param pid: 插件 ID
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (文件列表, 错误信息)
"""
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins"
# 如果 package_version 存在(如 "v2"),则加上版本号
if package_version:
file_api += f".{package_version}"
file_api += f"/{pid.lower()}"
res = self.__request_with_fallback(file_api,
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo),
is_api=True,
timeout=30)
if res is None:
return None, "连接仓库失败"
elif res.status_code == 404:
return None, "插件源码目录不存在"
elif res.status_code != 200:
return None, f"连接仓库失败:{res.status_code} - " \
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}"
try:
ret = res.json()
if isinstance(ret, list) and len(ret) > 0 and "message" not in ret[0]:
return ret, ""
else:
return None, "插件在仓库中不存在或返回数据格式不正确"
except Exception as e:
logger.error(f"插件数据解析失败:{e}")
return None, "插件数据解析失败"
def __download_files(self, pid: str, file_list: List[dict], user_repo: str,
package_version: Optional[str] = None) -> Tuple[bool, str]:
"""
下载插件文件
:param pid: 插件 ID
:param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接)
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (是否成功, 错误信息)
"""
if not file_list:
return False, "文件列表为空"
# 使用栈结构来替代递归调用,避免递归深度过大问题
stack = [(pid, file_list)]
while stack:
current_pid, current_file_list = stack.pop()
for item in current_file_list:
if item.get("download_url"):
logger.debug(f"正在下载文件:{item.get('path')}")
res = self.__request_with_fallback(item.get('download_url'),
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo))
if not res:
return False, f"文件 {item.get('path')} 下载失败!"
elif res.status_code != 200:
return False, f"下载文件 {item.get('path')} 失败:{res.status_code}"
# 确保文件路径不包含版本号(如 v2、v3),如果有 package_version,移除路径中的版本号
relative_path = item.get("path")
if package_version:
relative_path = relative_path.replace(f"plugins.{package_version}", "plugins", 1)
# 创建插件文件夹并写入文件
file_path = Path(get_runtime_setting('ROOT_PATH')) / "app" / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(res.text)
logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}")
else:
# 如果是子目录,则将子目录内容加入栈中继续处理
sub_list, msg = self.__get_file_list(f"{current_pid}/{item.get('name')}", user_repo,
package_version)
if not sub_list:
return False, msg
stack.append((f"{current_pid}/{item.get('name')}", sub_list))
return True, ""
def __install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:
"""
安装插件依赖。
:param pid: 插件 ID
:return: (是否存在依赖,安装是否成功, 错误信息)
"""
plugin_dir = PLUGIN_DIR / pid.lower()
try:
manifest = load_dependency_manifest(plugin_dir)
except PluginDependencyManifestError as error:
logger.error(f"{pid} 依赖清单无效:{error}")
return True, False, str(error)
if manifest is not None:
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
success, error_message = self.install_packages_with_fallback(manifest.path)
return True, success, "" if success else error_message
return False, False, "不存在依赖"
@staticmethod
def __backup_plugin(pid: str) -> str:
"""
备份旧插件目录
:param pid: 插件 ID
:return: 备份目录路径
"""
plugin_dir = PLUGIN_DIR / pid.lower()
backup_dir = Path(get_runtime_setting('TEMP_PATH')) / "plugin_backup" / pid.lower()
if plugin_dir.exists():
# 备份时清理已有的备份目录,防止残留文件影响
if backup_dir.exists():
shutil.rmtree(backup_dir, ignore_errors=True)
logger.debug(f"{pid} 旧的备份目录已清理 {backup_dir}")
shutil.copytree(plugin_dir, backup_dir, dirs_exist_ok=True)
logger.debug(f"{pid} 插件已备份到 {backup_dir}")
return str(backup_dir) if backup_dir.exists() else None
@staticmethod
def __restore_plugin(pid: str, backup_dir: str):
"""
还原旧插件目录
:param pid: 插件 ID
:param backup_dir: 备份目录路径
"""
plugin_dir = PLUGIN_DIR / pid.lower()
if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True)
logger.debug(f"{pid} 已清理插件目录 {plugin_dir}")
if Path(backup_dir).exists():
shutil.copytree(backup_dir, plugin_dir, dirs_exist_ok=True)
logger.debug(f"{pid} 已还原插件目录 {plugin_dir}")
shutil.rmtree(backup_dir, ignore_errors=True)
logger.debug(f"{pid} 已删除备份目录 {backup_dir}")
@staticmethod
def __remove_old_plugin(pid: str):
"""
删除旧插件
:param pid: 插件 ID
"""
plugin_dir = PLUGIN_DIR / pid.lower()
if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True)
@staticmethod
def refresh_persistent_plugin_backup(pid: str) -> bool:
"""
刷新插件持久化备份目录,供 docker 重置后恢复使用
"""
if not SystemUtils.is_docker():
return True
plugin_dir = PLUGIN_DIR / pid.lower()
if not plugin_dir.exists():
logger.warn(f"{pid} 插件目录不存在,跳过刷新插件备份")
return False
backup_root = get_runtime_setting('CONFIG_PATH') / "plugins_backup"
backup_dir = backup_root / pid.lower()
staging_dir = backup_root / f".{pid.lower()}.tmp-{uuid.uuid4().hex}"
previous_dir = backup_root / f".{pid.lower()}.old-{uuid.uuid4().hex}"
try:
backup_root.mkdir(parents=True, exist_ok=True)
shutil.copytree(
plugin_dir,
staging_dir,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
)
if backup_dir.exists():
backup_dir.replace(previous_dir)
staging_dir.replace(backup_dir)
if previous_dir.exists():
shutil.rmtree(previous_dir, ignore_errors=True)
logger.info(f"已刷新插件备份: {pid}")
return True
except Exception as e:
if not backup_dir.exists() and previous_dir.exists():
try:
previous_dir.replace(backup_dir)
except Exception as rollback_error:
logger.error(
f"恢复插件旧备份失败,已保留恢复材料 {previous_dir}: "
f"{rollback_error}"
)
logger.error(f"刷新插件备份失败: {pid} - {e}")
return False
finally:
if staging_dir.exists():
shutil.rmtree(staging_dir, ignore_errors=True)
if backup_dir.exists() and previous_dir.exists():
shutil.rmtree(previous_dir, ignore_errors=True)
def __collect_plugin_wheels_dirs(self) -> List[Path]:
"""
收集已安装插件目录下可用的 wheels 目录,供批量依赖安装时复用。
"""
wheels_dirs = []
try:
install_plugins = {
plugin_id.lower()
for plugin_id in _installed_plugins_provider() or []
}
for plugin_id in install_plugins:
wheels_dir = PLUGIN_DIR / plugin_id / "wheels"
if wheels_dir.is_dir():
wheels_dirs.append(wheels_dir)
except Exception as e:
logger.error(f"收集插件 wheels 目录时发生错误:{e}")
return []
# 去重并保持稳定顺序,避免重复传递相同目录
return list(dict.fromkeys(wheels_dirs))
@staticmethod
def __build_runtime_uv_check_command() -> List[str]:
"""构造绑定当前解释器环境的 uv 依赖诊断命令。"""
uv_bin = find_uv(Path(sys.executable))
if not uv_bin:
return []
return [str(uv_bin), "pip", "check", "--python", sys.executable]
@staticmethod
def __format_package_name(name: str) -> str:
"""将内部包名转换为依赖清单常用的连字符形式。"""
return name.replace("_", "-")
@staticmethod
def __marker_matches(marker, extra: str = "") -> bool:
"""
使用当前运行环境和可选 extra 上下文判断 marker 是否生效。
"""
if not marker:
return True
try:
env = default_environment()
env["extra"] = extra
return marker.evaluate(env)
except Exception as err:
logger.debug(f"依赖 marker 计算失败,按不匹配处理:{err}")
return False
@classmethod
def __parse_project_requirement_roots(
cls,
project_file: Path,
) -> Dict[str, Set[str]]:
"""解析主项目 pyproject,收集当前平台生效的根依赖和 extras。"""
roots = {}
if not project_file.exists():
logger.warning(f"主项目依赖文件不存在:{project_file}")
return roots
try:
for raw_requirement in iter_runtime_requirement_strings(project_file):
requirement = Requirement(raw_requirement)
if not cls.__marker_matches(requirement.marker):
continue
package_name = cls.__standardize_pkg_name(requirement.name)
roots.setdefault(package_name, set()).update(
extra.lower() for extra in requirement.extras
)
return roots
except Exception as e:
logger.error(f"解析主项目依赖文件失败:{project_file} - {e}")
return {}
@classmethod
def __get_installed_distribution_requirements(cls) -> Dict[str, Tuple[Version, List[Requirement]]]:
"""
获取当前环境中每个已安装包的依赖声明,用于展开主程序依赖图。
"""
requirement_graph = {}
try:
for dist in distributions():
name = dist.metadata.get("Name")
if not name:
continue
package_name = cls.__standardize_pkg_name(name)
version_str = dist.metadata.get("Version") or getattr(dist, "version", None)
if not version_str:
continue
try:
version = Version(version_str)
except InvalidVersion:
logger.debug(f"无法解析已安装包 '{package_name}' 的版本:{version_str}")
continue
requirements = []
for raw_requirement in dist.requires or []:
try:
requirements.append(Requirement(raw_requirement))
except Exception as err:
logger.debug(f"无法解析已安装包 '{package_name}' 的依赖项 '{raw_requirement}'{err}")
if package_name not in requirement_graph or version > requirement_graph[package_name][0]:
requirement_graph[package_name] = (version, requirements)
return requirement_graph
except Exception as e:
logger.error(f"收集已安装包依赖图时发生错误:{e}")
return {}
@classmethod
def __get_protected_runtime_packages(
cls,
installed_packages: Optional[Dict[str, Version]] = None
) -> Dict[str, Version]:
"""
仅收集主程序依赖图中的已安装包版本。
主项目 pyproject 中声明的根依赖及其当前已安装的传递依赖都会被冻结,
未被主程序依赖图引用的插件自带包允许后续插件按需升级或降级。
"""
if installed_packages is None:
installed_packages = cls.__get_installed_packages()
protected_packages = {
package_name: version
for package_name, version in installed_packages.items()
if package_name in cls._protected_runtime_packages
}
project_file = get_runtime_setting('ROOT_PATH') / "pyproject.toml"
root_requirements = cls.__parse_project_requirement_roots(project_file)
if not root_requirements:
return protected_packages
requirement_graph = cls.__get_installed_distribution_requirements()
active_extras = {
package_name: set(extras)
for package_name, extras in root_requirements.items()
}
pending_packages = deque(active_extras.keys())
processed_extras: Dict[str, Set[str]] = {}
while pending_packages:
package_name = pending_packages.popleft()
selected_extras = active_extras.get(package_name, set())
previous_extras = processed_extras.get(package_name)
if previous_extras is not None and selected_extras.issubset(previous_extras):
continue
processed_extras[package_name] = set(selected_extras)
if package_name in installed_packages:
protected_packages[package_name] = installed_packages[package_name]
_, requirements = requirement_graph.get(package_name, (None, []))
if not requirements:
continue
active_extra_values = [""] + sorted(selected_extras)
for requirement in requirements:
if requirement.marker and not any(
cls.__marker_matches(requirement.marker, extra)
for extra in active_extra_values
):
continue
dep_name = cls.__standardize_pkg_name(requirement.name)
known_extras = active_extras.setdefault(dep_name, set())
before_len = len(known_extras)
known_extras.update(extra.lower() for extra in requirement.extras)
if dep_name not in processed_extras or len(known_extras) != before_len:
pending_packages.append(dep_name)
return protected_packages
@classmethod
def __get_strict_runtime_packages(cls) -> Set[str]:
"""返回核心包及当前 ABI profile 中不得被插件改写的根包。"""
packages = set(cls._protected_runtime_packages)
project_file = get_runtime_setting('ROOT_PATH') / "pyproject.toml"
try:
for raw_requirement in iter_runtime_profile_requirement_strings(project_file):
requirement = Requirement(raw_requirement)
if cls.__marker_matches(requirement.marker):
packages.add(cls.__standardize_pkg_name(requirement.name))
except Exception as error:
logger.error(f"解析运行依赖 profile 失败:{project_file} - {error}")
return packages
@staticmethod
def __is_upgrade_only_conflict(specifier_set: SpecifierSet, installed_version: Version) -> bool:
"""
判断版本冲突是否只能通过升级来解决(specifier 允许的所有版本都严格高于已安装版本)。
返回 True 表示纯升级冲突;返回 False 表示可能需要降级或无法确定方向。
"""
has_lower_bound = False
for spec in specifier_set:
op = spec.operator
ver_str = spec.version.rstrip("*").rstrip(".") or "0"
try:
ver = Version(ver_str)
except InvalidVersion:
return False
if op in ("<", "<="):
upper = ver if op == "<" else Version(f"{ver}.post0")
if upper <= installed_version:
return False
elif op == "==":
if ver <= installed_version:
return False
elif op == "~=":
# ~=X.Y.Z 等价于 >=X.Y.Z, <X.(Y+1);若 X.Y.Z <= 已安装版本说明需降级
if ver <= installed_version:
return False
has_lower_bound = True
elif op in (">=", ">"):
has_lower_bound = True
# != 操作符:单独出现时可能允许低版本,需结合其他约束判断
# 若没有任何明确的下限约束(仅 != 等),保守地视为不确定 → 返回 False
return has_lower_bound
@classmethod
def __validate_runtime_dependency_conflicts(
cls,
dependency_file: Path,
protected_packages: Dict[str, Version]
) -> Tuple[bool, str]:
"""
在真正执行安装前,先拦截插件对主程序依赖的显式覆盖请求。
共享 venv 场景下,仅冻结主程序依赖;插件新增依赖、以及插件之间共享的额外依赖,
允许后续安装继续调整版本。
"""
conflicts = []
strict_packages = cls.__get_strict_runtime_packages()
try:
manifest = load_dependency_file(dependency_file)
for requirement in manifest.dependencies:
if not cls.__marker_matches(requirement.marker):
continue
package_name = cls.__standardize_pkg_name(requirement.name)
installed_version = protected_packages.get(package_name)
if installed_version is None:
continue
if requirement.url:
conflicts.append((
package_name,
str(installed_version),
f"来自 {requirement.url} 的同名包",
package_name in strict_packages,
))
continue
if requirement.specifier and not requirement.specifier.contains(
installed_version,
prereleases=True
):
is_core = package_name in strict_packages
# 非核心包的纯升级冲突允许放行,由安装约束控制实际版本。
if is_core or not cls.__is_upgrade_only_conflict(
requirement.specifier, installed_version):
conflicts.append((
package_name,
str(installed_version),
str(requirement.specifier),
is_core,
))
except Exception as e:
logger.error(f"执行运行环境依赖冲突预检时发生错误:{e}")
return False, f"插件依赖预检失败:{e}"
if not conflicts:
return True, ""
def sort_key(item: Tuple[str, str, str, bool]) -> Tuple[int, str]:
return 0 if item[3] else 1, item[0]
details = []
for package_name, installed_version, expected, _is_protected in sorted(conflicts, key=sort_key)[:5]:
details.append(
f"{cls.__format_package_name(package_name)} 当前为 {installed_version}"
f"插件要求 {expected}"
)
if len(conflicts) > 5:
details.append(f"其余 {len(conflicts) - 5} 项冲突已省略")
scope = "主程序核心依赖" if any(item[3] for item in conflicts) else "主程序依赖"
return False, (
f"插件依赖与当前运行环境的{scope}冲突:{''.join(details)}。"
f"为避免共享运行环境被污染,已拒绝安装。"
)
@classmethod
def __create_runtime_constraints_file(cls, protected_packages: Dict[str, Version]) -> Path:
"""
以主程序依赖的当前已安装版本生成临时约束文件,确保插件安装不会改写主程序依赖。
"""
temp_dir = Path(get_runtime_setting('TEMP_PATH')) / "plugin_dependencies"
temp_dir.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=temp_dir,
prefix="runtime-constraints-",
suffix=".txt",
delete=False
) as temp_file:
strict_packages = cls.__get_strict_runtime_packages()
for package_name, version in sorted(protected_packages.items()):
if package_name in strict_packages:
# 核心与 ABI profile 根包严格锁定,插件不得改写
temp_file.write(f"{cls.__format_package_name(package_name)}=={version}\n")
else:
# 非核心主程序依赖:允许升级,但禁止降级
temp_file.write(f"{cls.__format_package_name(package_name)}>={version}\n")
return Path(temp_file.name)
@classmethod
async def __async_create_runtime_constraints_file(
cls,
protected_packages: Dict[str, Version],
) -> Path:
"""创建临时约束文件,取消时等待创建收口并删除已产生的文件。"""
create_task = asyncio.create_task(
asyncio.to_thread(
cls.__create_runtime_constraints_file,
protected_packages,
)
)
try:
return await asyncio.shield(create_task)
except asyncio.CancelledError:
async def cleanup_created_file() -> None:
try:
created_file = await create_task
except BaseException:
return
await asyncio.to_thread(created_file.unlink, missing_ok=True)
cleanup_task = asyncio.create_task(cleanup_created_file())
try:
await await_task_to_terminal(cleanup_task)
except Exception as err:
logger.warning(f"[UV] 取消后清理运行环境约束文件失败:{err}")
raise
@staticmethod
def __refresh_import_system():
"""
依赖安装或修复后刷新当前解释器的导入缓存,保证后续动态导入能看到新状态。
"""
importlib.reload(site)
importlib.invalidate_caches()
@classmethod
def __build_package_install_request(
cls,
dependency_files: Path | Sequence[Path],
find_links_dirs: Optional[List[Path]] = None,
constraints_file: Optional[Path] = None,
purpose: str = "plugin",
) -> PackageInstallRequest:
"""
将 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(
dependency_files=resolved_dependency_files,
python_bin=Path(sys.executable),
find_links_dirs=find_links_dirs or [],
constraints_file=constraints_file,
config_dir=get_runtime_setting('CONFIG_PATH'),
package_cache_root=get_runtime_setting('PACKAGE_CACHE_PATH'),
package_index_url=get_runtime_setting('PIP_PROXY') or None,
proxy_url=get_runtime_setting('PROXY_HOST') or None,
purpose=purpose,
)
@classmethod
def __repair_if_runtime_broken(
cls,
snapshot_file: Optional[Path] = None,
baseline_health: Optional[Dict[str, Tuple[bool, str]]] = None
) -> Tuple[bool, str]:
"""
安装失败后检查主运行环境;若相对安装前新增异常,先恢复主程序依赖再返回。
"""
current_health = cls.__run_runtime_healthcheck()
health_message = cls.__runtime_health_regression_message(
baseline_health or {},
current_health
)
if not health_message:
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_health = cls.__run_runtime_healthcheck()
restored_message = cls.__runtime_health_regression_message(
baseline_health or {},
restored_health
)
if restored_message:
return False, f"插件依赖安装失败后主运行环境异常,恢复后仍异常:{restored_message}"
return True, "主运行环境已恢复"
@classmethod
def __run_runtime_healthcheck(cls) -> Dict[str, Tuple[bool, str]]:
"""
执行全部运行环境自检并返回逐项结果,避免前一项失败遮蔽后续异常。
"""
health_snapshot = {}
uv_check = cls.__build_runtime_uv_check_command()
if uv_check:
checks = [("uv check", uv_check)]
else:
health_snapshot["uv check"] = (False, "未找到 uv 可执行文件")
checks = []
checks.append(("核心依赖导入检查", [
sys.executable,
"-m",
cls._runtime_import_probe,
"--full",
]))
for check_name, command in checks:
success, message = SystemUtils.execute_with_subprocess(command)
health_snapshot[check_name] = (success, message)
return health_snapshot
@staticmethod
def __runtime_health_error_lines(check_name: str, message: str) -> set[str]:
"""提取未被项目依赖策略排除的稳定诊断项。"""
lines = {line.strip() for line in message.splitlines() if line.strip()}
if check_name != "uv check":
return lines
matches = list(re.finditer(
r"The package `(?P<package>[^`]+)` requires `(?P<requirement>[^`]+)`, "
r"but [^\r\n;]+",
message,
))
if not matches:
return lines
excluded_pairs = runtime_excluded_dependency_pairs(
Path(get_runtime_setting('ROOT_PATH')) / "pyproject.toml"
)
package_errors = set()
for match in matches:
try:
dependency_name = Requirement(match.group("requirement")).name
except InvalidRequirement:
package_errors.add(match.group(0))
continue
pair = (
canonicalize_name(match.group("package")),
canonicalize_name(dependency_name),
)
if pair not in excluded_pairs:
package_errors.add(match.group(0))
return package_errors
@staticmethod
def __runtime_health_regression_message(
baseline_health: Dict[str, Tuple[bool, str]],
current_health: Dict[str, Tuple[bool, str]]
) -> str:
"""
汇总相对基线新增的异常;已有诊断失败不能遮蔽后续新增错误。
"""
regressions = []
for check_name, (success, message) in current_health.items():
baseline_success, baseline_message = baseline_health.get(check_name, (True, ""))
if baseline_success and not success:
current_lines = PluginHelper.__runtime_health_error_lines(
check_name,
message,
)
if current_lines:
regressions.append(
f"{check_name}失败:{' | '.join(sorted(current_lines))}"
)
elif not baseline_success and not success:
baseline_lines = PluginHelper.__runtime_health_error_lines(
check_name,
baseline_message,
)
current_lines = PluginHelper.__runtime_health_error_lines(
check_name,
message,
)
added_lines = sorted(current_lines - baseline_lines)
if added_lines:
regressions.append(f"{check_name}新增错误:{' | '.join(added_lines)}")
return "".join(regressions)
@classmethod
def __repair_main_runtime_dependencies(cls, snapshot_file: Optional[Path] = None) -> Tuple[bool, str]:
"""
依赖安装后如果发现主运行环境已异常,优先恢复主程序依赖快照;
若快照不可用,再按主项目锁定依赖恢复运行环境。
"""
repair_target = snapshot_file
repair_desc = "主程序依赖快照"
if repair_target and not repair_target.exists():
repair_target = None
if repair_target is None:
repair_target = get_runtime_setting('ROOT_PATH') / "pyproject.toml"
repair_desc = "主程序 uv.lock"
if not repair_target.exists():
return False, f"恢复依赖文件不存在:{repair_target}"
if snapshot_file is None and not (get_runtime_setting('ROOT_PATH') / "uv.lock").exists():
return False, f"恢复依赖文件不存在:{get_runtime_setting('ROOT_PATH') / 'uv.lock'}"
last_error = ""
request = cls.__build_package_install_request(repair_target, purpose="runtime-repair")
strategies = (
build_package_install_strategies(request)
if snapshot_file is not None
else build_project_sync_strategies(request)
)
for strategy in strategies:
logger.warning(f"[UV] 运行环境异常,尝试使用策略:{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"[UV] 使用策略:{strategy.strategy_name} 恢复{repair_desc}失败:{message}")
return False, last_error or f"恢复{repair_desc}失败"
@classmethod
def install_packages_with_fallback(cls,
dependency_files: Path | Sequence[Path],
find_links_dirs: Optional[List[Path]] = None) -> Tuple[bool, str]:
"""
使用自动降级策略安装依赖,并确保新安装的包可被动态导入
:param dependency_files: 一个或多个插件依赖清单路径
:param find_links_dirs: 额外的本地 wheels 目录列表
:return: (是否成功, 错误信息)
"""
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 = []
for dependency_file in resolved_dependency_files:
wheels_dir = dependency_file.parent / "wheels"
if wheels_dir.is_dir():
candidate_dirs.append(wheels_dir)
if find_links_dirs:
candidate_dirs.extend(find_links_dirs)
# 去重并保持传入顺序
resolved_dirs = []
seen_dirs = set()
for candidate_dir in candidate_dirs:
candidate_path = Path(candidate_dir)
if not candidate_path.is_dir():
continue
candidate_key = str(candidate_path.resolve())
if candidate_key in seen_dirs:
continue
seen_dirs.add(candidate_key)
resolved_dirs.append(candidate_path)
if resolved_dirs:
for local_wheels_dir in resolved_dirs:
logger.debug(f"[UV] 发现可用的 wheels 目录: {local_wheels_dir},将优先从本地安装。")
else:
logger.debug("[UV] 未发现可用的 wheels 目录,将仅使用在线源。")
installed_packages = cls.__get_installed_packages()
protected_packages = cls.__get_protected_runtime_packages(installed_packages)
for dependency_file in resolved_dependency_files:
check_ok, check_message = cls.__validate_runtime_dependency_conflicts(
dependency_file,
protected_packages,
)
if not check_ok:
logger.error(f"[UV] 运行环境冲突预检失败:{check_message}")
return False, check_message
constraints_file = None
if protected_packages:
try:
constraints_file = cls.__create_runtime_constraints_file(protected_packages)
except Exception as e:
logger.error(f"[UV] 创建运行环境约束文件失败:{e}")
return False, f"创建运行环境约束文件失败:{e}"
request = cls.__build_package_install_request(
resolved_dependency_files,
find_links_dirs=resolved_dirs,
constraints_file=constraints_file,
purpose="plugin",
)
strategies = build_package_install_strategies(request)
try:
# 安装器会修改当前解释器的 site-packages,安装与缓存刷新必须串行。
with cls._package_install_lock:
loaded_modules_before_install = set(sys.modules.keys())
baseline_health = cls.__run_runtime_healthcheck()
baseline_health_message = cls.__runtime_health_regression_message({}, baseline_health)
if baseline_health_message:
logger.warning(
f"[UV] 安装前运行环境已存在异常,本次安装仅拦截新增异常:{baseline_health_message}"
)
# 遍历策略进行安装
last_error = ""
for strategy in strategies:
logger.debug(
f"[UV] 尝试使用策略:{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"[UV] 策略:{strategy.strategy_name} 安装依赖成功,输出:{message}")
current_health = cls.__run_runtime_healthcheck()
health_message = cls.__runtime_health_regression_message(
baseline_health,
current_health
)
if health_message:
logger.error(f"[UV] 依赖安装后运行环境自检失败:{health_message}")
repair_ok, repair_message = cls.__repair_main_runtime_dependencies(
constraints_file if protected_packages else None
)
if repair_ok:
restored_health = cls.__run_runtime_healthcheck()
restored_message = cls.__runtime_health_regression_message(
baseline_health,
restored_health
)
if not restored_message:
cls.__refresh_import_system()
return False, (
f"依赖安装后运行环境自检失败,已自动恢复主程序依赖:{health_message}"
)
logger.error(
f"[UV] 主程序依赖恢复后仍未通过健康检查:{restored_message}"
)
return False, (
f"依赖安装后运行环境自检失败,恢复主程序依赖后仍异常:"
f"{restored_message}"
)
return False, (
f"依赖安装后运行环境自检失败,且自动恢复主程序依赖失败:"
f"{repair_message}"
)
remaining_health_message = cls.__runtime_health_regression_message({}, current_health)
if remaining_health_message:
logger.warning(
f"[UV] 依赖安装成功,安装前已有的运行环境异常仍然存在:"
f"{remaining_health_message}"
)
cls.__refresh_import_system()
loaded_modules_after_install = set(sys.modules.keys())
loaded_modules_during_install = loaded_modules_after_install - loaded_modules_before_install
logger.debug(f"[UV] 已刷新导入系统,新加载的模块: {loaded_modules_during_install}")
return True, message
last_error = message
repair_ok, repair_message = cls.__repair_if_runtime_broken(
constraints_file if protected_packages else None,
baseline_health
)
logger.error(f"[UV] 策略:{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"[UV] 所有策略均安装依赖失败:{last_error}"
return False, "[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束"
@staticmethod
def _build_github_request_strategies(
url: str,
headers: Optional[dict] = None,
timeout: Optional[int] = 60,
is_api: bool = False,
) -> List[Tuple[str, str, dict]]:
"""构造同步与异步 GitHub 请求共用的镜像、代理和直连顺序。"""
strategies: List[Tuple[str, str, dict]] = []
if not is_api and get_runtime_setting('GITHUB_PROXY'):
proxy_url = (
f"{UrlUtils.standardize_base_url(get_runtime_setting('GITHUB_PROXY'))}{url}"
)
strategies.append(
("镜像站", proxy_url, {"headers": headers, "timeout": timeout})
)
if get_runtime_setting('PROXY_HOST'):
strategies.append(
(
"代理",
url,
{
"headers": headers,
"proxies": get_runtime_setting('PROXY'),
"timeout": timeout,
},
)
)
strategies.append(
("直连", url, {"headers": headers, "timeout": timeout})
)
return strategies
@staticmethod
def __request_with_fallback(url: str,
headers: Optional[dict] = None,
timeout: Optional[int] = 60,
is_api: bool = False) -> Optional[Response]:
"""
使用自动降级策略,请求资源,优先级依次为镜像站、代理、直连
:param url: 目标URL
:param headers: 请求头信息
:param timeout: 请求超时时间
:param is_api: 是否为GitHub API请求,API请求不走镜像站
:return: 请求成功则返回 Response,失败返回 None
"""
strategies = PluginHelper._build_github_request_strategies(
url=url,
headers=headers,
timeout=timeout,
is_api=is_api,
)
# 遍历策略并尝试请求
for strategy_name, target_url, request_params in strategies:
logger.debug(f"[GitHub] 尝试使用策略:{strategy_name} 请求 URL{target_url}")
try:
res = RequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}")
return res
except Exception as e:
logger.error(f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url},错误:{str(e)}")
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
return None
def __get_plugin_meta(self, pid: str, repo_url: str,
package_version: Optional[str]) -> dict:
"""读取远端插件元数据,并把异常收敛为空映射。"""
try:
plugins = (
self.get_plugins(repo_url) if not package_version
else self.get_plugins(repo_url, package_version)
) or {}
meta = plugins.get(pid)
return meta if isinstance(meta, dict) else {}
except Exception as e:
logger.error(f"获取插件 {pid} 元数据失败:{e}")
return {}
def get_plugin_system_version_check_message(self, pid: str, repo_url: str) -> Optional[str]:
"""
获取指定插件来源的主系统版本兼容错误;兼容或无法定位元数据时返回 None。
"""
if not pid or not repo_url:
return None
if self.is_local_repo_url(repo_url):
candidate = self.get_local_plugin_candidate(
pid=pid,
package_version=self.parse_local_repo_package_version(repo_url),
repo_path=self.parse_local_repo_path(repo_url),
strict_compat=False
)
if not candidate:
return None
compatible, message = self.check_plugin_system_version(candidate)
return None if compatible else message
package_version = self.get_plugin_package_version(pid, repo_url, get_runtime_setting('VERSION_FLAG'))
if package_version is None:
return None
meta = self.__get_plugin_meta(pid, repo_url, package_version)
compatible, message = self.check_plugin_system_version(meta)
return None if compatible else message
async def async_get_plugin_system_version_check_message(self, pid: str, repo_url: str) -> Optional[str]:
"""
异步获取指定插件来源的主系统版本兼容错误;兼容或无法定位元数据时返回 None。
"""
if not pid or not repo_url:
return None
if self.is_local_repo_url(repo_url):
return await asyncio.to_thread(self.get_plugin_system_version_check_message, pid, repo_url)
package_version = await self.async_get_plugin_package_version(pid, repo_url, get_runtime_setting('VERSION_FLAG'))
if package_version is None:
return None
meta = await self.__async_get_plugin_meta(pid, repo_url, package_version)
compatible, message = self.check_plugin_system_version(meta)
return None if compatible else message
def __install_flow_sync(self, pid: str, force_install: bool,
prepare_content: Callable[[], Tuple[bool, str]],
repo_url: Optional[str] = None) -> Tuple[bool, str]:
"""
同步安装统一流程:备份→清理→准备内容→安装依赖→上报
prepare_content 负责把插件文件放到 app/plugins/{pid}
"""
backup_dir = None
if not force_install:
backup_dir = self.__backup_plugin(pid)
self.__remove_old_plugin(pid)
success, message = prepare_content()
if not success:
logger.error(f"{pid} 准备插件内容失败:{message}")
if backup_dir:
self.__restore_plugin(pid, backup_dir)
logger.warn(f"{pid} 插件安装失败,已还原备份插件")
else:
self.__remove_old_plugin(pid)
logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装")
return False, message
dependencies_exist, dep_ok, dep_msg = self.__install_dependencies_if_required(pid)
if dependencies_exist and not dep_ok:
logger.error(f"{pid} 依赖安装失败:{dep_msg}")
if backup_dir:
self.__restore_plugin(pid, backup_dir)
logger.warn(f"{pid} 插件安装失败,已还原备份插件")
else:
self.__remove_old_plugin(pid)
logger.warn(f"{pid} 已清理对应插件目录,请尝试重新安装")
return False, dep_msg
if backup_dir:
shutil.rmtree(backup_dir, ignore_errors=True)
return True, ""
@staticmethod
def __validate_release_zip_name(name: str) -> None:
"""
校验 release zip 成员名在 POSIX 与 Windows 语义下都只能表示相对路径。
"""
if not name:
raise ValueError("非法 Release 压缩包成员:成员名为空")
if "\x00" in name:
raise ValueError(f"非法 Release 压缩包成员:{name}")
if "\\" in name:
raise ValueError(f"非法 Release 压缩包成员:{name}")
posix_path = PurePosixPath(name)
windows_path = PureWindowsPath(name)
if (
name.startswith("//")
or posix_path.is_absolute()
or windows_path.is_absolute()
or windows_path.drive
):
raise ValueError(f"非法 Release 压缩包成员:{name}")
parts = [part for part in posix_path.parts if part not in ("", ".")]
if not parts:
raise ValueError(f"非法 Release 压缩包成员:{name}")
if ".." in parts:
raise ValueError(f"非法 Release 压缩包成员:{name}")
@staticmethod
def __validate_release_zip_type(info: zipfile.ZipInfo) -> None:
"""
release zip 只接受普通文件和目录,避免归档内的符号链接或设备文件影响安装边界。
"""
mode = info.external_attr >> 16
file_type = stat.S_IFMT(mode)
if not file_type:
return
if stat.S_ISREG(mode) or stat.S_ISDIR(mode):
return
raise ValueError(f"非法 Release 压缩包成员:{info.filename}")
@staticmethod
def __get_release_zip_base_prefix(infos: List[zipfile.ZipInfo]) -> str:
"""
识别 release zip 的单一顶层目录,用于保持插件包根目录剥离行为。
"""
names = [info.filename for info in infos]
names_with_slash = [name for name in names if "/" in name]
if names_with_slash and len(names_with_slash) == len(names):
first_seg = names_with_slash[0].split("/", 1)[0]
if first_seg and all(name.startswith(first_seg + "/") for name in names):
return first_seg + "/"
return ""
@classmethod
def __iter_release_zip_targets(
cls, zf: zipfile.ZipFile, dest_base: Path
) -> List[Tuple[zipfile.ZipInfo, Path, bool]]:
"""
将 release zip 成员解析为安装目标路径,并保证目标路径不会逃逸插件目录。
"""
infos = zf.infolist()
for info in infos:
cls.__validate_release_zip_type(info)
cls.__validate_release_zip_name(info.filename)
base_prefix = cls.__get_release_zip_base_prefix(infos)
dest_root = dest_base.resolve()
targets = []
for info in infos:
raw_name = info.filename
rel_name = raw_name[len(base_prefix):] if base_prefix else raw_name
if not rel_name:
if base_prefix and raw_name == base_prefix:
continue
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
cls.__validate_release_zip_name(rel_name)
rel_parts = [part for part in PurePosixPath(rel_name).parts if part not in ("", ".")]
if not rel_parts:
raise ValueError(f"非法 Release 压缩包成员:{raw_name}")
dest_path = (dest_root / Path(*rel_parts)).resolve()
try:
dest_path.relative_to(dest_root)
except ValueError as exc:
raise ValueError(f"非法 Release 压缩包成员:{raw_name}") from exc
targets.append((info, dest_path, info.is_dir()))
return targets
def __install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:
"""
通过 GitHub Release 资产文件安装插件。
规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件;
将其全部解压到 app/plugins/{pid}
"""
# 拼接资产文件名
asset_name = f"{release_tag.lower()}.zip"
release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}"
rel_res = self.__request_with_fallback(
release_api,
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo),
timeout=30,
is_api=True,
)
if rel_res is None:
return False, "获取 Release 信息失败:连接失败"
if rel_res.status_code == 404:
return False, f"{release_tag} 插件发布包不存在"
if rel_res.status_code != 200:
return False, f"获取 Release 信息失败:{rel_res.status_code}"
try:
rel_json = rel_res.json()
assets = rel_json.get("assets") or []
asset = next((a for a in assets if a.get("name") == asset_name), None)
if not asset:
return False, f"未找到资产文件:{asset_name}"
asset_id = asset.get("id")
if not asset_id:
return False, "资产缺少ID信息"
# 构建资产的API下载URL
download_url = f"https://api.github.com/repos/{user_repo}/releases/assets/{asset_id}"
except Exception as e:
logger.error(f"解析 Release 信息失败:{e}")
return False, f"解析 Release 信息失败:{e}"
# 使用资产的API端点下载,需要设置Accept头为application/octet-stream
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy()
headers["Accept"] = "application/octet-stream"
res = self.__request_with_fallback(download_url, headers=headers, is_api=True)
if res is None or res.status_code != 200:
return False, f"下载资产失败:{res.status_code if res else '连接失败'}"
try:
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
infos = zf.infolist()
if not infos:
return False, "压缩包内容为空"
dest_base = Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" / pid.lower()
targets = self.__iter_release_zip_targets(zf, dest_base)
wrote_any = False
for info, dest_path, is_dir in targets:
if is_dir:
dest_path.mkdir(parents=True, exist_ok=True)
continue
dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info, 'r') as src, open(dest_path, 'wb') as dst:
dst.write(src.read())
wrote_any = True
if not wrote_any:
return False, "压缩包中无可写入文件"
return True, ""
except Exception as e:
logger.error(f"解压 Release 压缩包失败:{e}")
return False, f"解压 Release 压缩包失败:{e}"
def find_missing_dependencies(self) -> List[str]:
"""兼容旧市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).find_missing()
def install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:
"""兼容旧市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).install(dependencies)
@classmethod
def __get_installed_packages(cls) -> Dict[str, Version]:
"""
获取已安装的包及其版本
使用 importlib.metadata 获取当前环境中已安装的包,标准化包名并转换版本信息
对于无法解析的版本,记录警告日志并跳过
:return: 已安装包的字典,格式为 {package_name: Version}
"""
installed_packages = {}
try:
for dist in distributions():
name = dist.metadata.get("Name")
if not name:
continue
pkg_name = cls.__standardize_pkg_name(name)
version_str = dist.metadata.get("Version") or getattr(dist, "version", None)
if not version_str:
continue
try:
v = Version(version_str)
if pkg_name not in installed_packages or v > installed_packages[pkg_name]:
installed_packages[pkg_name] = v
except InvalidVersion:
logger.debug(f"无法解析已安装包 '{pkg_name}' 的版本:{version_str}")
continue
return installed_packages
except Exception as e:
logger.error(f"获取已安装的包时发生错误:{e}")
return {}
@staticmethod
def __standardize_pkg_name(name: str) -> str:
"""
标准化包名,将包名转换为小写,连字符与点替换为下划线(与 PEP 503 归一化风格一致)
:param name: 原始包名
:return: 标准化后的包名
"""
if not name:
return name
return name.lower().replace("-", "_").replace(".", "_")
async def async_get_plugin_package_version(self, pid: str, repo_url: str,
package_version: Optional[str] = None) -> Optional[str]:
"""
异步版本的获取插件版本方法,功能同 get_plugin_package_version
"""
for candidate in self._package_version_candidates(package_version):
selected = self._select_compatible_package_version(
pid=pid,
package_version=candidate,
plugins=await self.async_get_plugins(
repo_url,
candidate or None,
),
)
if selected is not None:
return selected
return None
@staticmethod
async def __async_request_with_fallback(url: str,
headers: Optional[dict] = None,
timeout: Optional[int] = 60,
is_api: bool = False) -> Optional[httpx2.Response]:
"""
使用自动降级策略,异步请求资源,优先级依次为镜像站、代理、直连
:param url: 目标URL
:param headers: 请求头信息
:param timeout: 请求超时时间
:param is_api: 是否为GitHub API请求,API请求不走镜像站
:return: 请求成功则返回 Response,失败返回 None
"""
strategies = PluginHelper._build_github_request_strategies(
url=url,
headers=headers,
timeout=timeout,
is_api=is_api,
)
# 遍历策略并尝试请求
for strategy_name, target_url, request_params in strategies:
logger.debug(f"[GitHub] 尝试使用策略:{strategy_name} 请求 URL{target_url}")
try:
res = await AsyncRequestUtils(**request_params).get_res(url=target_url, raise_exception=True)
logger.debug(f"[GitHub] 请求成功,策略:{strategy_name}, URL: {target_url}")
return res
except Exception as e:
logger.error(f"[GitHub] 请求失败,策略:{strategy_name}, URL: {target_url},错误:{str(e)}")
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
return None
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
async def async_get_plugin_index_result(
self,
repo_url: str,
package_version: Optional[str] = None,
) -> Optional[Dict[str, Dict[str, Any]]]:
"""异步读取插件索引;404 返回 None,读取失败由调用方记录。"""
request = self._build_plugin_index_request(repo_url, package_version)
if request is None:
raise ValueError("插件仓库地址无效")
package_url, headers = request
res = await self.__async_request_with_fallback(
package_url,
headers=headers,
)
if res is None:
raise RuntimeError("插件索引请求失败:连接失败")
if res.status_code == 404:
return None
if res.status_code != 200:
raise RuntimeError(f"插件索引请求失败:HTTP {res.status_code}")
payload = self.__parse_plugin_index_response(res.text)
if payload is None:
raise RuntimeError("插件索引响应格式无效")
return payload
async def async_get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
"""
异步获取 Github 插件列表,保留旧的 dict/{}/None 兼容返回。
:param repo_url: Github仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
"""
try:
payload = await self.async_get_plugin_index_result(
repo_url,
package_version,
)
except (ValueError, RuntimeError):
return None
return payload if payload is not None else {}
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases")
async def _async_get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
"""
异步按仓库获取 GitHub Release 原始分页数据。
"""
releases = []
for release_api, headers in self._iter_plugin_release_page_requests(
repo_url
):
res = await self.__async_request_with_fallback(
release_api,
headers=headers,
timeout=30,
is_api=True,
)
should_continue = self._merge_plugin_release_page(
repo_url,
res,
releases,
)
if should_continue is None:
return None
if not should_continue:
break
return releases
async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
"""
异步获取插件可安装的 GitHub Release 版本列表。
同一事件循环内,同仓库的并发读取和强制刷新共享一个请求任务。
"""
if not pid or not repo_url:
return []
loop = asyncio.get_running_loop()
normalized_repo_url = repo_url.rstrip("/")
normal_task_key = (loop, normalized_repo_url, False)
force_task_key = (loop, normalized_repo_url, True)
with self._release_task_lock:
if is_fresh():
force_task = self._release_tasks.get(force_task_key)
if force_task and not force_task.done():
task_key = force_task_key
task = force_task
else:
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task and pending_normal_task.done():
pending_normal_task = None
task_key = force_task_key
task = get_task_registry().create(
self._async_refresh_plugin_repo_releases(
normalized_repo_url,
pending_normal_task,
),
owner="plugin.market.release_refresh",
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task_key = normal_task_key
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task is None or pending_normal_task.done():
task = get_task_registry().create(
self._async_get_plugin_repo_releases(normalized_repo_url),
owner="plugin.market.release_read",
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task = pending_normal_task
payload = await asyncio.shield(task)
return self.__parse_plugin_release_response(pid, payload)
async def async_has_plugin_release_cache(self, repo_url: str) -> bool:
"""
判断指定仓库的 Release 列表缓存是否已经存在。
"""
if not repo_url:
return False
return await self._async_get_plugin_repo_releases.cache_exists(
self, repo_url.rstrip("/")
)
async def _async_refresh_plugin_repo_releases(
self,
repo_url: str,
pending_normal_task: Optional[asyncio.Task],
) -> Optional[List[dict]]:
"""等待在途普通读取落盘后执行强刷,确保旧结果不会覆盖强刷缓存。"""
if pending_normal_task:
try:
await asyncio.shield(pending_normal_task)
except (Exception, asyncio.CancelledError):
pass
return await self._async_get_plugin_repo_releases(repo_url)
@classmethod
def _remove_release_task(cls, task_key: Tuple[asyncio.AbstractEventLoop, str, bool], task: asyncio.Task) -> None:
"""请求任务完成后释放事件循环和仓库引用。"""
with cls._release_task_lock:
if cls._release_tasks.get(task_key) is task:
cls._release_tasks.pop(task_key, None)
async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
Tuple[Optional[list], Optional[str]]:
"""
异步获取插件的文件列表
:param pid: 插件 ID
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (文件列表, 错误信息)
"""
file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins"
# 如果 package_version 存在(如 "v2"),则加上版本号
if package_version:
file_api += f".{package_version}"
file_api += f"/{pid.lower()}"
res = await self.__async_request_with_fallback(file_api,
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo),
is_api=True,
timeout=30)
if res is None:
return None, "连接仓库失败"
elif res.status_code == 404:
return None, "插件源码目录不存在"
elif res.status_code != 200:
return None, f"连接仓库失败:{res.status_code} - " \
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.text}"
try:
ret = res.json()
if isinstance(ret, list) and len(ret) > 0 and "message" not in ret[0]:
return ret, ""
else:
return None, "插件在仓库中不存在或返回数据格式不正确"
except Exception as e:
logger.error(f"插件数据解析失败:{e}")
return None, "插件数据解析失败"
async def __async_download_files(self, pid: str, file_list: List[dict], user_repo: str,
package_version: Optional[str] = None) -> Tuple[bool, str]:
"""
异步下载插件文件
:param pid: 插件 ID
:param file_list: 要下载的文件列表,包含文件的元数据(包括下载链接)
:param user_repo: GitHub 仓库的 user/repo 路径
:return: (是否成功, 错误信息)
"""
if not file_list:
return False, "文件列表为空"
# 使用栈结构来替代递归调用,避免递归深度过大问题
stack = [(pid, file_list)]
while stack:
current_pid, current_file_list = stack.pop()
for item in current_file_list:
if item.get("download_url"):
logger.debug(f"正在下载文件:{item.get('path')}")
res = await self.__async_request_with_fallback(item.get('download_url'),
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo))
if not res:
return False, f"文件 {item.get('path')} 下载失败!"
elif res.status_code != 200:
return False, f"下载文件 {item.get('path')} 失败:{res.status_code}"
# 确保文件路径不包含版本号(如 v2、v3),如果有 package_version,移除路径中的版本号
relative_path = item.get("path")
if package_version:
relative_path = relative_path.replace(f"plugins.{package_version}", "plugins", 1)
# 创建插件文件夹并写入文件
file_path = AsyncPath(get_runtime_setting('ROOT_PATH')) / "app" / relative_path
await file_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(file_path, "w", encoding="utf-8") as f:
await f.write(res.text)
logger.debug(f"文件 {item.get('path')} 下载成功,保存路径:{file_path}")
else:
# 如果是子目录,则将子目录内容加入栈中继续处理
sub_list, msg = await self.__async_get_file_list(f"{current_pid}/{item.get('name')}", user_repo,
package_version)
if not sub_list:
return False, msg
stack.append((f"{current_pid}/{item.get('name')}", sub_list))
return True, ""
@classmethod
async def __async_run_runtime_healthcheck(cls) -> Dict[str, Tuple[bool, str]]:
"""异步执行插件安装后的运行环境检查。"""
health_snapshot: Dict[str, Tuple[bool, str]] = {}
uv_check = cls.__build_runtime_uv_check_command()
if uv_check:
checks = [("uv check", uv_check)]
else:
health_snapshot["uv check"] = (False, "未找到 uv 可执行文件")
checks = []
checks.append(("核心依赖导入检查", [
sys.executable,
"-m",
cls._runtime_import_probe,
"--full",
]))
for check_name, command in checks:
health_snapshot[check_name] = (
await SystemUtils.execute_with_subprocess_async(
command,
timeout=30,
)
)
return health_snapshot
@classmethod
async def __async_repair_main_runtime_dependencies(
cls,
snapshot_file: Optional[Path] = None,
) -> Tuple[bool, str]:
"""异步恢复主程序运行依赖,避免修复命令绕过可取消进程边界。"""
repair_target = snapshot_file
repair_desc = "主程序依赖快照"
if repair_target and not await _await_thread_operation(repair_target.exists):
repair_target = None
if repair_target is None:
repair_target = get_runtime_setting('ROOT_PATH') / "pyproject.toml"
repair_desc = "主程序 uv.lock"
if not await _await_thread_operation(repair_target.exists):
return False, f"恢复依赖文件不存在:{repair_target}"
lock_file = get_runtime_setting('ROOT_PATH') / "uv.lock"
if snapshot_file is None and not await _await_thread_operation(lock_file.exists):
return False, f"恢复依赖文件不存在:{get_runtime_setting('ROOT_PATH') / 'uv.lock'}"
request = cls.__build_package_install_request(
repair_target,
purpose="runtime-repair",
)
strategies = (
build_package_install_strategies(request)
if snapshot_file is not None
else build_project_sync_strategies(request)
)
last_error = ""
for strategy in strategies:
logger.warning(
f"[UV] 运行环境异常,尝试使用策略:{strategy.strategy_name} 恢复{repair_desc}"
)
success, message = await SystemUtils.execute_with_subprocess_async(
strategy.command,
env=strategy.env,
safe_command=strategy.safe_log_command,
timeout=cls.PLUGIN_DEPENDENCY_INSTALL_TIMEOUT,
)
if success:
cls.__refresh_import_system()
return True, message
last_error = message
logger.error(
f"[UV] 使用策略:{strategy.strategy_name} 恢复{repair_desc}失败:{message}"
)
return False, last_error or f"恢复{repair_desc}失败"
@classmethod
async def __async_repair_if_runtime_broken(
cls,
snapshot_file: Optional[Path],
baseline_health: Dict[str, Tuple[bool, str]],
) -> Tuple[bool, str]:
"""异步检查并修复安装过程中新增的主程序环境异常。"""
current_health = await cls.__async_run_runtime_healthcheck()
health_message = cls.__runtime_health_regression_message(
baseline_health,
current_health,
)
if not health_message:
return True, ""
repair_ok, repair_message = (
await cls.__async_repair_main_runtime_dependencies(snapshot_file)
)
if not repair_ok:
return False, (
f"插件依赖安装失败后主运行环境异常,且恢复失败:"
f"{health_message}; {repair_message}"
)
restored_health = await cls.__async_run_runtime_healthcheck()
restored_message = cls.__runtime_health_regression_message(
baseline_health,
restored_health,
)
if restored_message:
return False, (
f"插件依赖安装失败后主运行环境异常,恢复后仍异常:"
f"{restored_message}"
)
return True, "主运行环境已恢复"
async def async_install_packages_with_fallback(
self,
dependency_files: Path | Sequence[Path],
find_links_dirs: Optional[List[Path]] = None,
) -> Tuple[bool, str]:
"""通过可取消子进程异步安装一组插件依赖清单。"""
return await self.__async_install_packages_with_fallback(
dependency_files,
find_links_dirs,
)
@classmethod
async def __async_install_packages_with_fallback(
cls,
dependency_files: Path | Sequence[Path],
find_links_dirs: Optional[List[Path]] = None,
) -> Tuple[bool, str]:
"""异步安装插件依赖,并让取消能够终止 uv 子进程。"""
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 = []
for dependency_file in resolved_dependency_files:
wheels_dir = dependency_file.parent / "wheels"
if await _await_thread_operation(wheels_dir.is_dir):
candidate_dirs.append(wheels_dir)
if find_links_dirs:
candidate_dirs.extend(find_links_dirs)
resolved_dirs = []
seen_dirs = set()
for candidate_dir in candidate_dirs:
candidate_path = Path(candidate_dir)
if not await _await_thread_operation(candidate_path.is_dir):
continue
candidate_key = str(
await _await_thread_operation(candidate_path.resolve)
)
if candidate_key in seen_dirs:
continue
seen_dirs.add(candidate_key)
resolved_dirs.append(candidate_path)
installed_packages = await _await_thread_operation(
cls.__get_installed_packages,
)
protected_packages = await _await_thread_operation(
cls.__get_protected_runtime_packages,
installed_packages,
)
for dependency_file in resolved_dependency_files:
check_ok, check_message = await _await_thread_operation(
cls.__validate_runtime_dependency_conflicts,
dependency_file,
protected_packages,
)
if not check_ok:
logger.error(f"[UV] 运行环境冲突预检失败:{check_message}")
return False, check_message
constraints_file = None
if protected_packages:
try:
constraints_file = await cls.__async_create_runtime_constraints_file(
protected_packages,
)
except Exception as err:
logger.error(f"[UV] 创建运行环境约束文件失败:{err}")
return False, f"创建运行环境约束文件失败:{err}"
request = cls.__build_package_install_request(
resolved_dependency_files,
find_links_dirs=resolved_dirs,
constraints_file=constraints_file,
purpose="plugin",
)
strategies = build_package_install_strategies(request)
acquired = False
try:
while not cls._package_install_lock.acquire(blocking=False):
await asyncio.sleep(0.01)
acquired = True
baseline_health = await cls.__async_run_runtime_healthcheck()
baseline_health_message = cls.__runtime_health_regression_message(
{},
baseline_health,
)
if baseline_health_message:
logger.warning(
f"[UV] 安装前运行环境已存在异常,本次安装仅拦截新增异常:"
f"{baseline_health_message}"
)
last_error = ""
for strategy in strategies:
logger.debug(
f"[UV] 尝试使用策略:{strategy.strategy_name} 安装依赖,"
f"命令:{' '.join(strategy.safe_log_command)}"
)
success, message = await SystemUtils.execute_with_subprocess_async(
strategy.command,
env=strategy.env,
safe_command=strategy.safe_log_command,
timeout=cls.PLUGIN_DEPENDENCY_INSTALL_TIMEOUT,
)
if success:
current_health = await cls.__async_run_runtime_healthcheck()
health_message = cls.__runtime_health_regression_message(
baseline_health,
current_health,
)
if health_message:
logger.error(f"[UV] 依赖安装后运行环境自检失败:{health_message}")
repair_ok, repair_message = (
await cls.__async_repair_main_runtime_dependencies(
constraints_file if protected_packages else None
)
)
if repair_ok:
restored_health = await cls.__async_run_runtime_healthcheck()
restored_message = cls.__runtime_health_regression_message(
baseline_health,
restored_health,
)
if not restored_message:
cls.__refresh_import_system()
return False, (
f"依赖安装后运行环境自检失败,已自动恢复主程序依赖:"
f"{health_message}"
)
return False, (
f"依赖安装后运行环境自检失败,恢复主程序依赖后仍异常:"
f"{restored_message}"
)
return False, (
f"依赖安装后运行环境自检失败,且自动恢复主程序依赖失败:"
f"{repair_message}"
)
cls.__refresh_import_system()
return True, message
last_error = message
repair_ok, repair_message = await cls.__async_repair_if_runtime_broken(
constraints_file if protected_packages else None,
baseline_health,
)
logger.error(
f"[UV] 策略:{strategy.strategy_name} 安装依赖失败,错误信息:{message}"
)
if not repair_ok or repair_message:
return False, (
f"策略 {strategy.strategy_name} 安装依赖失败:{message}"
f"{repair_message}"
)
return False, (
f"[UV] 所有策略均安装依赖失败:{last_error}"
if last_error
else "[UV] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束"
)
finally:
if acquired:
cls._package_install_lock.release()
if constraints_file:
await _await_thread_operation(
constraints_file.unlink,
missing_ok=True,
)
async def __async_backup_plugin(self, pid: str) -> str:
"""
异步备份旧插件目录
:param pid: 插件 ID
:return: 备份目录路径
"""
plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()
backup_dir = AsyncPath(get_runtime_setting('TEMP_PATH')) / "plugin_backup" / pid.lower()
if await plugin_dir.exists():
try:
if await backup_dir.exists():
await aioshutil.rmtree(backup_dir, ignore_errors=True)
logger.debug(f"{pid} 旧的备份目录已清理 {backup_dir}")
await self._async_copytree(plugin_dir, backup_dir)
logger.debug(f"{pid} 插件已备份到 {backup_dir}")
except asyncio.CancelledError:
await aioshutil.rmtree(backup_dir, ignore_errors=True)
raise
return str(backup_dir) if await backup_dir.exists() else None
async def __async_restore_plugin(self, pid: str, backup_dir: str):
"""
异步还原旧插件目录
:param pid: 插件 ID
:param backup_dir: 备份目录路径
"""
plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()
if await plugin_dir.exists():
await aioshutil.rmtree(plugin_dir, ignore_errors=True)
logger.debug(f"{pid} 已清理插件目录 {plugin_dir}")
backup_path = AsyncPath(backup_dir)
if await backup_path.exists():
await self._async_copytree(src=backup_path, dst=plugin_dir)
logger.debug(f"{pid} 已还原插件目录 {plugin_dir}")
await aioshutil.rmtree(backup_path, ignore_errors=True)
logger.debug(f"{pid} 已删除备份目录 {backup_dir}")
@staticmethod
async def __async_remove_old_plugin(pid: str):
"""
异步删除旧插件
:param pid: 插件 ID
"""
plugin_dir = AsyncPath(PLUGIN_DIR) / pid.lower()
if await plugin_dir.exists():
await aioshutil.rmtree(plugin_dir, ignore_errors=True)
async def _async_copytree(self, src: AsyncPath, dst: AsyncPath):
"""
异步递归复制目录
:param src: 源目录
:param dst: 目标目录
"""
if not await src.exists():
return
await dst.mkdir(parents=True, exist_ok=True)
async for item in src.iterdir():
dst_item = dst / item.name
if await item.is_dir():
await self._async_copytree(item, dst_item)
else:
async with aiofiles.open(item, 'rb') as src_file:
content = await src_file.read()
async with aiofiles.open(dst_item, 'wb') as dst_file:
await dst_file.write(content)
async def __async_install_dependencies_if_required(self, pid: str) -> Tuple[bool, bool, str]:
"""
异步安装插件依赖。
:param pid: 插件 ID
:return: (是否存在依赖,安装是否成功, 错误信息)
"""
plugin_dir = PLUGIN_DIR / pid.lower()
try:
manifest = load_dependency_manifest(plugin_dir)
except PluginDependencyManifestError as error:
logger.error(f"{pid} 依赖清单无效:{error}")
return True, False, str(error)
if manifest is not None:
logger.info(f"{pid} 存在依赖,开始尝试安装依赖")
success, error_message = await self.__async_install_packages_with_fallback(manifest.path)
return True, success, "" if success else error_message
return False, False, "不存在依赖"
async def async_install_dependencies(self, dependencies: List[str]) -> Tuple[bool, str]:
"""兼容旧异步市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return await installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).async_install(dependencies)
async def async_find_missing_dependencies(self) -> List[str]:
"""兼容旧异步市场入口,转发到独立依赖适配器。"""
installer = importlib.import_module(
"app.adapters.system.plugin.dependency"
).PluginDependencyInstaller
return await installer(
self,
installed_plugins_provider=_installed_plugins_provider,
plugin_dir=PLUGIN_DIR,
).async_find_missing()
async def async_install(self, pid: str, repo_url: str, package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False) -> Tuple[bool, str]:
"""
通过宿主统一 Gateway 安装插件,保留第三方插件使用的异步兼容 API。
:param pid: 插件 ID
:param repo_url: 插件仓库地址
:param package_version: 首选插件版本 (如 "v2", "v3"),如不指定则默认使用系统配置的版本
:param release_version: 指定安装的 release 资产版本;未指定时安装当前索引版本
:param force_install: 是否替换已存在的插件载荷
:return: (是否成功, 错误信息)
"""
return await _async_plugin_install_gateway(
pid,
repo_url,
package_version,
release_version,
force_install,
)
async def __async_install_package(
self,
pid: str,
repo_url: str,
package_version: Optional[str] = None,
release_version: Optional[str] = None,
force_install: bool = False,
) -> Tuple[bool, str]:
"""执行已通过来源准入的异步包安装,不负责身份或运行态提交。"""
if self.is_local_repo_url(repo_url):
return await _await_thread_operation(
self.__install_local_package,
pid,
repo_url,
force_install,
)
if SystemUtils.is_frozen():
return False, "可执行文件模式下,只能安装本地插件"
# 验证参数
if not pid or not repo_url:
return False, "参数错误"
# 从 GitHub 的 repo_url 获取用户和项目名
user, repo = self.get_repo_info(repo_url)
if not user or not repo:
return False, "不支持的插件仓库地址格式"
user_repo = f"{user}/{repo}"
if not package_version:
package_version = get_runtime_setting('VERSION_FLAG')
# 1. 优先检查指定版本的插件
package_version = await self.async_get_plugin_package_version(pid, repo_url, package_version)
# 如果 package_version 为None,说明没有找到匹配的插件
if package_version is None:
msg = f"{pid} 没有找到适用于当前版本的插件"
logger.debug(msg)
return False, msg
# package_version 为空,表示从 package.json 中找到插件
elif package_version == "":
logger.debug(f"{pid} 从 package.json 中找到适用于当前版本的插件")
else:
logger.debug(f"{pid} 从 package.{package_version}.json 中找到适用于当前版本的插件")
# 2. 统一异步安装流程(release 或文件列表)。
meta = await self.__async_get_plugin_meta(pid, repo_url, package_version)
release_items = (
await self.async_get_plugin_release_versions(pid, repo_url)
if release_version
else []
)
plan, message = self._build_remote_plugin_install_plan(
pid=pid,
meta=meta,
release_version=release_version,
release_items=release_items,
)
if plan is None:
return False, message
release_tag = plan.release_tag
if release_tag and not plan.fallback_to_filelist:
async def prepare_selected_release() -> Tuple[bool, str]:
return await self.__async_install_from_release(
pid,
user_repo,
release_tag,
)
return await self.__install_flow_async(pid, force_install, prepare_selected_release, repo_url)
if release_tag:
# 当前索引 Release 失败时回退文件列表,保持同步与异步安装一致。
async def prepare_release() -> Tuple[bool, str]:
ok, msg = await self.__async_install_from_release(
pid,
user_repo,
release_tag,
)
if ok:
return True, msg
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
await self.__async_remove_old_plugin(pid)
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
return await self.__install_flow_async(pid, force_install, prepare_release, repo_url)
# 未声明 release 打包的插件继续使用文件列表方式安装。
async def prepare_filelist() -> Tuple[bool, str]:
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
return await self.__install_flow_async(pid, force_install, prepare_filelist, repo_url)
async def __async_get_plugin_meta(self, pid: str, repo_url: str,
package_version: Optional[str]) -> dict:
"""异步读取远端插件元数据,并把异常收敛为空映射。"""
try:
plugins = (
await self.async_get_plugins(repo_url) if not package_version
else await self.async_get_plugins(repo_url, package_version)
) or {}
meta = plugins.get(pid)
return meta if isinstance(meta, dict) else {}
except Exception as e:
logger.warn(f"获取插件 {pid} 元数据失败:{e}")
return {}
async def __install_flow_async(self, pid: str, force_install: bool,
prepare_content: Callable[[], Awaitable[Tuple[bool, str]]],
repo_url: Optional[str] = None) -> Tuple[bool, str]:
"""
异步安装流程,处理插件内容准备、依赖安装和注册
"""
backup_dir = None
try:
if not force_install:
backup_dir = await self.__async_backup_plugin(pid)
await self.__async_remove_old_plugin(pid)
success, message = await prepare_content()
if not success:
logger.error(f"{pid} 准备插件内容失败:{message}")
if backup_dir:
await self.__async_restore_plugin(pid, backup_dir)
logger.warning(f"{pid} 插件安装失败,已还原备份插件")
else:
await self.__async_remove_old_plugin(pid)
logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装")
return False, message
dependencies_exist, dep_ok, dep_msg = (
await self.__async_install_dependencies_if_required(pid)
)
if dependencies_exist and not dep_ok:
logger.error(f"{pid} 依赖安装失败:{dep_msg}")
if backup_dir:
await self.__async_restore_plugin(pid, backup_dir)
logger.warning(f"{pid} 插件安装失败,已还原备份插件")
else:
await self.__async_remove_old_plugin(pid)
logger.warning(f"{pid} 已清理对应插件目录,请尝试重新安装")
return False, dep_msg
return True, ""
except asyncio.CancelledError:
logger.warning(
f"{pid} 插件安装被取消,Python 依赖环境可能已经改变"
)
raise
finally:
if backup_dir:
await aioshutil.rmtree(backup_dir, ignore_errors=True)
def __prepare_content_via_filelist_sync(self, pid: str, user_repo: str,
package_version: Optional[str]) -> Tuple[bool, str]:
"""
同步准备插件内容,通过文件列表获取插件文件和依赖
"""
runtime_pid = pid.lower()
file_list, msg = self.__get_file_list(runtime_pid, user_repo, package_version)
if not file_list:
if msg == "插件源码目录不存在":
return False, f"{pid} {msg}"
return False, msg
ok, m = self.__download_files(runtime_pid, file_list, user_repo, package_version)
if not ok:
return False, m
return True, ""
async def __prepare_content_via_filelist_async(self, pid: str, user_repo: str,
package_version: Optional[str]) -> Tuple[bool, str]:
"""
异步准备插件内容,通过文件列表获取插件文件和依赖
"""
runtime_pid = pid.lower()
file_list, msg = await self.__async_get_file_list(
runtime_pid,
user_repo,
package_version,
)
if not file_list:
if msg == "插件源码目录不存在":
return False, f"{pid} {msg}"
return False, msg
ok, m = await self.__async_download_files(
runtime_pid,
file_list,
user_repo,
package_version,
)
if not ok:
return False, m
return True, ""
async def __async_install_from_release(self, pid: str, user_repo: str, release_tag: str) -> Tuple[bool, str]:
"""
通过 GitHub Release 资产文件安装插件(异步)。
规范:release 中存在名为 "{pid}_v{version}.zip" 的资产,zip 根即插件文件;
将其全部解压到 app/plugins/{pid}
"""
# 拼接资产文件名
asset_name = f"{release_tag.lower()}.zip"
release_api = f"https://api.github.com/repos/{user_repo}/releases/tags/{release_tag}"
rel_res = await self.__async_request_with_fallback(
release_api,
headers=get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo),
timeout=30,
is_api=True,
)
if rel_res is None:
return False, "获取 Release 信息失败:连接失败"
if rel_res.status_code == 404:
return False, f"{release_tag} 插件发布包不存在"
if rel_res.status_code != 200:
return False, f"获取 Release 信息失败:{rel_res.status_code}"
try:
rel_json = rel_res.json()
assets = rel_json.get("assets") or []
asset = next((a for a in assets if a.get("name") == asset_name), None)
if not asset:
return False, f"未找到资产文件:{asset_name}"
asset_id = asset.get("id")
if not asset_id:
return False, "资产缺少ID信息"
# 构建资产的API下载URL
download_url = f"https://api.github.com/repos/{user_repo}/releases/assets/{asset_id}"
except Exception as e:
logger.error(f"解析 Release 信息失败:{e}")
return False, f"解析 Release 信息失败:{e}"
# 使用资产的API端点下载,需要设置Accept头为application/octet-stream
headers = get_runtime_setting('REPO_GITHUB_HEADERS')(repo=user_repo).copy()
headers["Accept"] = "application/octet-stream"
res = await self.__async_request_with_fallback(download_url,
headers=headers,
is_api=True)
if res is None or res.status_code != 200:
return False, f"下载资产失败:{res.status_code if res else '连接失败'}"
try:
with zipfile.ZipFile(io.BytesIO(res.content)) as zf:
infos = zf.infolist()
if not infos:
return False, "压缩包内容为空"
dest_base = Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" / pid.lower()
targets = self.__iter_release_zip_targets(zf, dest_base)
wrote_any = False
for info, dest_path, is_dir in targets:
async_dest_path = AsyncPath(dest_path)
if is_dir:
await async_dest_path.mkdir(parents=True, exist_ok=True)
continue
await async_dest_path.parent.mkdir(parents=True, exist_ok=True)
data = await asyncio.to_thread(
self.__read_release_zip_member,
zf,
info,
)
async with aiofiles.open(dest_path, 'wb') as dst:
await dst.write(data)
wrote_any = True
if not wrote_any:
return False, "压缩包中无可写入文件"
return True, ""
except Exception as e:
logger.error(f"解压 Release 压缩包失败:{e}")
return False, f"解压 Release 压缩包失败:{e}"
@staticmethod
def __read_release_zip_member(zf: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
"""在线程池读取并解压单个 Release 文件,避免阻塞事件循环。"""
with zf.open(info, "r") as source:
return source.read()
# 公开 Release 查询的缓存管理统一指向仓库级分页缓存。
PluginHelper.get_plugin_release_versions.cache_clear = PluginHelper._get_plugin_repo_releases.cache_clear
PluginHelper.get_plugin_release_versions.cache_region = PluginHelper._get_plugin_repo_releases.cache_region
PluginHelper.async_get_plugin_release_versions.cache_clear = PluginHelper._async_get_plugin_repo_releases.cache_clear
PluginHelper.async_get_plugin_release_versions.cache_region = PluginHelper._async_get_plugin_repo_releases.cache_region