From fa40f29dfda0cc2e384d5699ee682e60d092fce4 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 29 Aug 2026 12:55:46 +0800 Subject: [PATCH] refactor(plugin): converge runtime ownership and compatibility --- app/adapters/external/market.py | 3637 +---------------- app/adapters/external/plugin/client.py | 1534 ++++++- app/adapters/system/plugin/dependency.py | 37 +- app/adapters/system/plugin/health.py | 1060 +++++ app/adapters/system/plugin/package.py | 1606 +++++++- app/agent/tools/impl/_plugin_tool_utils.py | 23 +- app/api/endpoints/plugin.py | 19 +- app/api/endpoints/system.py | 2 +- app/application/plugin/admission.py | 2 +- app/application/plugin/inventory.py | 29 +- app/application/plugin/source.py | 13 - app/domain/plugin.py | 228 ++ app/runtime/extensions/plugin/dependency.py | 54 +- app/runtime/extensions/plugin/runtime.py | 324 ++ app/runtime/extensions/plugin/system.py | 34 +- app/runtime/extensions/plugin_manager.py | 383 +- app/startup/initializers/plugins.py | 69 +- app/startup/lifecycle/__init__.py | 9 +- docs/architecture-optimization-checklist.md | 13 +- docs/architecture-overview.md | 4 +- docs/rules/05-architecture.md | 20 +- tests/conftest.py | 58 +- .../architecture/coverage-baseline.json | 12 +- .../architecture/dependency-baseline.json | 128 +- .../architecture/dependency-policy.json | 4 +- .../architecture/filename-policy.json | 160 + .../fixtures/architecture/mypy-baseline.json | 34 +- .../official-plugin-baseline.json | 64 +- .../fixtures/architecture/ruff-baseline.json | 6 - tests/test_agent_plugin_tools.py | 39 + tests/test_architecture_contract_baseline.py | 9 + tests/test_architecture_egress.py | 8 +- tests/test_legacy_plugin_resource_imports.py | 32 +- tests/test_lifecycle_shutdown.py | 9 +- tests/test_plugin_backup_restore.py | 33 +- tests/test_plugin_compat_governance.py | 214 + tests/test_plugin_endpoint.py | 27 +- .../test_plugin_external_install_boundary.py | 100 +- tests/test_plugin_folders.py | 175 +- tests/test_plugin_helper.py | 1082 ++--- tests/test_plugin_helper_governance.py | 311 ++ tests/test_plugin_install_backup_cleanup.py | 38 +- tests/test_plugin_install_plan_policy.py | 49 +- tests/test_plugin_local_sync.py | 73 +- tests/test_plugin_manager_governance.py | 188 + tests/test_plugin_market_index_policy.py | 47 +- tests/test_plugin_market_request_policy.py | 18 +- tests/test_plugin_package_manager.py | 175 +- .../test_plugin_package_version_resolution.py | 20 +- tests/test_plugin_release_page_policy.py | 10 +- tests/test_runtime_execution.py | 7 +- 51 files changed, 7403 insertions(+), 4827 deletions(-) create mode 100644 app/adapters/system/plugin/health.py create mode 100644 app/domain/plugin.py create mode 100644 app/runtime/extensions/plugin/runtime.py create mode 100644 tests/fixtures/architecture/filename-policy.json create mode 100644 tests/test_plugin_compat_governance.py create mode 100644 tests/test_plugin_helper_governance.py create mode 100644 tests/test_plugin_manager_governance.py diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index 11d2f126f..59d627f8b 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -1,107 +1,25 @@ -import asyncio -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 collections import deque -from dataclasses import dataclass -from importlib.metadata import distributions -from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import Any, Awaitable, Callable, Dict, Iterator, List, Optional, Sequence, Set, Tuple -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 InvalidSpecifier, SpecifierSet -from packaging.utils import canonicalize_name -from packaging.version import InvalidVersion, Version -from requests import Response +from __future__ import annotations -from app.adapters.network.http import AsyncRequestUtils, RequestUtils -from app.adapters.system.host import SystemUtils -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, - dependency_manifest_declares_installation, - load_dependency_file, - load_dependency_manifest, -) -from app.foundation.environment import is_free_threaded_runtime +from collections.abc import Awaitable, Callable, Sequence +from pathlib import Path +from typing import List, Optional, Tuple + +from app.adapters.external.plugin.client import PluginMarketTransport, is_local_repo_url +from app.adapters.system.plugin.dependency import PluginDependencyInstaller +from app.adapters.system.plugin.health import PluginRuntimeHealth from app.foundation.singleton import WeakSingleton -from app.foundation.url import UrlUtils -from app.foundation.version import compare_version -from app.runtime.cache import cached, is_fresh -from app.runtime.dependencies.profile import ( - iter_runtime_profile_requirement_strings, - iter_runtime_requirement_strings, - runtime_excluded_dependency_pairs, -) -from app.runtime.execution import ( - await_task_to_terminal, -) -from app.runtime.execution import ( - run_in_threadpool_to_completion as _await_thread_operation, -) -from app.runtime.log import logger from app.runtime.observability import observe_compat_facade from app.runtime.settings import get_runtime_setting -from app.runtime.tasks import get_task_registry -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_WIKI_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 +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]]] def _empty_installed_plugins() -> List[str]: @@ -109,48 +27,37 @@ 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, + _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, + _pid: str, _repo_url: str, _package_version: Optional[str], + _release_version: Optional[str], _force_install: bool, ) -> Tuple[bool, str]: """在组合根尚未装配来源门禁时拒绝异步插件包写入。""" return False, "插件安装服务尚未完成初始化" +_installed_plugins_provider: InstalledPluginsProvider = _empty_installed_plugins _plugin_install_gateway: PluginInstallGateway = _unconfigured_plugin_install_gateway -_async_plugin_install_gateway: AsyncPluginInstallGateway = ( - _unconfigured_async_plugin_install_gateway -) +_async_plugin_install_gateway: AsyncPluginInstallGateway = _unconfigured_async_plugin_install_gateway +_plugin_market_transport = PluginMarketTransport() +_plugin_runtime_health = PluginRuntimeHealth() -def configure_installed_plugins_provider( - provider: InstalledPluginsProvider, -) -> None: - """由启动组合层注入已安装插件读取器,避免市场适配器访问数据库。""" + +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, + *, install: PluginInstallGateway, async_install: AsyncPluginInstallGateway, ) -> None: """由启动组合根装配公开兼容安装入口的来源门禁。""" global _plugin_install_gateway, _async_plugin_install_gateway @@ -165,3419 +72,155 @@ def reset_plugin_install_gateway() -> None: _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 is_free_threaded_runtime() and plugin_info.get("v3t") is False: - 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 - if is_free_threaded_runtime() and plugin_info.get("v3t") is False: - 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, - before_dependency_install: Optional[Callable[[], None]] = None) \ - -> 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, - before_dependency_install=before_dependency_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, - before_dependency_install, - ) - - 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, - before_dependency_install, - ) - # 未声明 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, - before_dependency_install, - ) - - def install_local(self, pid: str, repo_url: str = "", force_install: bool = False) -> Tuple[bool, str]: + """保留第三方插件公开 ABI,并把行为委托给独立 owner。""" + + PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = PluginRuntimeHealth.PLUGIN_DEPENDENCY_INSTALL_TIMEOUT + + is_local_repo_url = staticmethod(is_local_repo_url) + make_local_repo_url = staticmethod(PluginMarketTransport.make_local_repo_url) + parse_local_repo_url = staticmethod(PluginMarketTransport.parse_local_repo_url) + parse_local_repo_path = staticmethod(PluginMarketTransport.parse_local_repo_path) + parse_local_repo_package_version = staticmethod(PluginMarketTransport.parse_local_repo_package_version) + get_current_system_version = staticmethod(PluginMarketTransport.get_current_system_version) + get_compatible_version_flags = staticmethod(PluginMarketTransport.get_compatible_version_flags) + is_plugin_info_compatible = staticmethod(PluginMarketTransport.is_plugin_info_compatible) + is_package_plugin_compatible = staticmethod(PluginMarketTransport.is_package_plugin_compatible) + check_plugin_system_version = staticmethod(PluginMarketTransport.check_plugin_system_version) + annotate_plugin_system_version = staticmethod(PluginMarketTransport.annotate_plugin_system_version) + get_local_repo_paths = staticmethod(PluginMarketTransport.get_local_repo_paths) + get_local_plugin_candidates = staticmethod(_plugin_market_transport.get_local_plugin_candidates) + get_local_plugin_candidate = staticmethod(_plugin_market_transport.get_local_plugin_candidate) + get_plugin_index_result = staticmethod(_plugin_market_transport.get_plugin_index_result) + get_plugins = staticmethod(_plugin_market_transport.get_plugins) + get_plugin_release_versions = staticmethod(_plugin_market_transport.get_plugin_release_versions) + get_plugin_package_version = staticmethod(_plugin_market_transport.get_plugin_package_version) + get_repo_info = staticmethod(PluginMarketTransport.get_repo_info) + request_with_fallback = staticmethod(PluginMarketTransport.request_with_fallback) + get_plugin_system_version_check_message = staticmethod( + _plugin_market_transport.get_plugin_system_version_check_message + ) + async_get_plugin_package_version = staticmethod( + _plugin_market_transport.async_get_plugin_package_version + ) + async_request_with_fallback = staticmethod( + PluginMarketTransport.async_request_with_fallback + ) + async_get_plugin_index_result = staticmethod( + _plugin_market_transport.async_get_plugin_index_result + ) + async_get_plugins = staticmethod(_plugin_market_transport.async_get_plugins) + async_get_plugin_release_versions = staticmethod( + _plugin_market_transport.async_get_plugin_release_versions + ) + async_has_plugin_release_cache = staticmethod( + _plugin_market_transport.async_has_plugin_release_cache + ) + async_get_plugin_system_version_check_message = staticmethod( + _plugin_market_transport.async_get_plugin_system_version_check_message + ) + + 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 安装插件。""" + return _plugin_install_gateway(pid, repo_url, package_version, release_version, force_install) + + 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, + 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, - before_dependency_install: Optional[Callable[[], None]] = None, + @classmethod + def install_packages_with_fallback( + cls, dependency_files: Path | Sequence[Path], + find_links_dirs: Optional[List[Path]] = None, ) -> 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 + """把兼容入口委托给系统运行环境 owner。""" + return _plugin_runtime_health.install_packages_with_fallback( + dependency_files, find_links_dirs ) - 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") - ), - before_dependency_install=before_dependency_install, - ) - - 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, - before_dependency_install: Optional[Callable[[], None]] = None, - ) -> 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: - if ( - before_dependency_install is not None - and dependency_manifest_declares_installation(manifest) - ): - try: - before_dependency_install() - except Exception as error: # noqa: BLE001 - 观察失败不能阻断安装 - logger.warning(f"{pid} 依赖安装前状态记录失败:{error}") - 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, =", ">"): - 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[^`]+)` requires `(?P[^`]+)`, " - 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, - before_dependency_install: Optional[Callable[[], None]] = 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, - before_dependency_install, - ) - if before_dependency_install is not None - else 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, + """把兼容入口委托给依赖聚合 owner。""" + return PluginDependencyInstaller( + _plugin_runtime_health, 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, + """把兼容入口委托给依赖聚合 owner。""" + return PluginDependencyInstaller( + _plugin_runtime_health, 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, + cls, 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, + """把异步兼容入口委托给系统运行环境 owner。""" + return await _plugin_runtime_health.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, + async def async_install_dependencies( + self, dependencies: List[str], ) -> 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, - before_dependency_install: Optional[Callable[[], None]] = None, - ) -> 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: - if ( - before_dependency_install is not None - and dependency_manifest_declares_installation(manifest) - ): - try: - await _await_thread_operation(before_dependency_install) - except Exception as error: # noqa: BLE001 - 观察失败不能阻断安装 - logger.warning(f"{pid} 依赖安装前状态记录失败:{error}") - 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, + """把异步兼容入口委托给依赖聚合 owner。""" + return await PluginDependencyInstaller( + _plugin_runtime_health, 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, + """把异步兼容入口委托给依赖聚合 owner。""" + return await PluginDependencyInstaller( + _plugin_runtime_health, 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: (是否成功, 错误信息) - """ + 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 异步安装插件。""" return await _async_plugin_install_gateway( - pid, - repo_url, - package_version, - release_version, - force_install, + 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, - before_dependency_install: Optional[Callable[[], None]] = None, - ) -> 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, - before_dependency_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, - before_dependency_install, - ) - - 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, - before_dependency_install, - ) - # 未声明 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, - before_dependency_install, - ) - - 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, - before_dependency_install: Optional[Callable[[], None]] = 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, - before_dependency_install, - ) - if before_dependency_install is not None - else 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 +setattr( + PluginHelper.get_plugin_release_versions, + "cache_clear", + getattr(_plugin_market_transport._get_plugin_repo_releases, "cache_clear"), +) +setattr( + PluginHelper.get_plugin_release_versions, + "cache_region", + getattr(_plugin_market_transport._get_plugin_repo_releases, "cache_region"), +) +setattr( + PluginHelper.async_get_plugin_release_versions, + "cache_clear", + getattr( + _plugin_market_transport._async_get_plugin_repo_releases, + "cache_clear", + ), +) +setattr( + PluginHelper.async_get_plugin_release_versions, + "cache_region", + getattr( + _plugin_market_transport._async_get_plugin_repo_releases, + "cache_region", + ), +) diff --git a/app/adapters/external/plugin/client.py b/app/adapters/external/plugin/client.py index bbb2942e6..a4f5616d5 100644 --- a/app/adapters/external/plugin/client.py +++ b/app/adapters/external/plugin/client.py @@ -2,51 +2,1329 @@ from __future__ import annotations +import asyncio +import json +import re +import threading +import time +import traceback from pathlib import Path -from typing import Any, Optional, cast +from typing import Any, Dict, Iterator, List, Optional, cast +from urllib.parse import urlparse, urlsplit -from app.adapters.external.market import PluginHelper as _PluginHelper -from app.runtime.cache import async_fresh, fresh +import httpx2 +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version +from requests import Response + +from app.adapters.network.http import AsyncRequestUtils, RequestUtils +from app.domain.plugin import ( + build_local_plugin_source, + check_plugin_system_version, + is_local_plugin_source, + is_plugin_generation_compatible, + parse_local_plugin_generation, + parse_local_plugin_path, + parse_local_plugin_reference, +) +from app.foundation.environment import is_free_threaded_runtime +from app.foundation.url import UrlUtils +from app.foundation.version import compare_version +from app.runtime.cache import async_fresh, cached, fresh, is_fresh +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting +from app.runtime.tasks import get_task_registry +from app.runtime.version import get_app_version + +PluginPayload = dict[str, Any] +PluginIndex = dict[str, PluginPayload] +PluginReleaseList = list[PluginPayload] +PluginRequestOptions = dict[str, Any] +PluginReleaseTask = asyncio.Task[Optional[PluginReleaseList]] + + +def build_local_repo_url( + plugin_id: str, + *, + repo_path: Optional[Path] = None, + package_version: Optional[str] = None, +) -> str: + """生成兼容插件 API 使用的本地仓库来源标识。""" + return build_local_plugin_source( + plugin_id, + repo_path=str(repo_path) if repo_path is not None else None, + package_generation=package_version, + ) + + +def is_local_repo_url(repo_url: Optional[str]) -> bool: + """判断插件来源是否为本地仓库标识。""" + return is_local_plugin_source(repo_url) + + +def parse_local_repo_plugin_id(repo_url: str) -> Optional[str]: + """从本地仓库来源标识提取插件 ID。""" + return parse_local_plugin_reference(repo_url) + + +def parse_local_repo_path( + repo_url: str, + *, + root_path: Optional[Path] = None, +) -> Optional[Path]: + """解析本地来源中的仓库路径,并相对宿主根目录完成定位。""" + try: + raw_path = parse_local_plugin_path(repo_url) + if not raw_path: + return None + path = Path(raw_path).expanduser() + if not path.is_absolute(): + if root_path is None: + return None + path = root_path / path + return path.resolve() + except (OSError, TypeError, ValueError): + return None + + +def parse_local_repo_generation(repo_url: str) -> Optional[str]: + """解析本地来源标识中的 package 代际参数。""" + return parse_local_plugin_generation(repo_url) + + +PLUGIN_SYSTEM_VERSION_FIELD = "system_version" +PLUGIN_MARKET_WIKI_START = "" +PLUGIN_MARKET_WIKI_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"], +} + +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 + +class PluginMarketTransport: + """负责插件市场、本地仓库和 GitHub 元数据读取。""" + + _base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/" + _release_task_lock = threading.Lock() + _release_tasks: dict[ + tuple[asyncio.AbstractEventLoop, str, bool], + PluginReleaseTask, + ] = {} + + @staticmethod + def is_local_repo_url(repo_url: Optional[str]) -> bool: + """ + 判断是否为本地插件来源标识 + """ + return is_local_repo_url(repo_url) + + @staticmethod + def make_local_repo_url(pid: str, repo_path: Optional[Path] = None, + package_version: Optional[str] = None) -> str: + """ + 生成本地插件安装来源标识 + """ + return build_local_repo_url( + pid, + repo_path=repo_path, + package_version=package_version, + ) + + @staticmethod + def parse_local_repo_url(repo_url: str) -> Optional[str]: + """ + 从本地插件来源标识中解析插件ID + """ + return parse_local_repo_plugin_id(repo_url) + + @staticmethod + def parse_local_repo_path(repo_url: str) -> Optional[Path]: + """ + 从本地插件来源标识中解析仓库路径 + """ + return parse_local_repo_path( + repo_url, + root_path=( + Path(root_path) + if (root_path := get_runtime_setting('ROOT_PATH')) is not None + else None + ), + ) + + @staticmethod + def parse_local_repo_package_version(repo_url: str) -> Optional[str]: + """ + 从本地插件来源标识中解析 package 版本 + """ + return parse_local_repo_generation(repo_url) + + @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[PluginPayload] + ) -> bool: + """ + 判断 package.json 中的插件元数据是否兼容当前主程序版本。 + + 默认索引需要声明当前版本;V3 临时兼容已声明 V2 的共享实现, + 但显式 ``v3: false`` 始终优先拒绝。 + """ + if not isinstance(plugin_info, dict): + return False + if is_free_threaded_runtime() and plugin_info.get("v3t") is False: + 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[PluginPayload], + package_version: Optional[str], + ) -> bool: + """ + 判断指定索引中的插件条目能否在当前主程序版本使用。 + + 当前代专用索引直接兼容。V3 临时默认兼容 V2 专用索引, + 除非条目显式声明 ``v3: false``;默认索引仍需先声明 ``v2: true``。 + """ + if not isinstance(plugin_info, dict): + return False + if is_free_threaded_runtime() and plugin_info.get("v3t") is False: + 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[PluginIndex], + ) -> 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[PluginPayload] + ) -> 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: PluginPayload + ) -> PluginPayload: + """ + 为插件 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[PluginIndex]: + """ + 从本地插件仓库读取 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) -> PluginIndex: + """ + 扫描本地插件仓库,按插件ID保留版本号最高的候选 + """ + candidates: PluginIndex = {} + 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[PluginPayload]: + """ + 获取指定插件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") + elif not strict_system_version and is_compatible: + candidate.pop("compatible", None) + candidate.pop("skip_reason", None) + 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[PluginIndex]: + """ + 解析插件索引响应,仅缓存成功解析出的字典结果。 + """ + 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, PluginRequestOptions]]: + """构造插件索引请求,统一仓库解析、代际文件名和鉴权请求头。""" + 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[PluginIndex]: + """统一解释插件索引 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: PluginPayload + ) -> Optional[PluginPayload]: + """ + 从 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: object + ) -> PluginReleaseList: + """ + 解析 GitHub release 列表,过滤出当前插件可直接安装的 release 资产。 + """ + if not isinstance(payload, list): + return [] + + releases: PluginReleaseList = [] + for release_info in payload: + item = PluginMarketTransport.__build_plugin_release_item(pid, release_info) + if item: + releases.append(item) + return releases + + @staticmethod + def __normalize_plugin_release_response( + payload: object, + ) -> PluginReleaseList: + """仅保留版本展示和资产匹配所需字段,控制仓库级缓存体积。""" + 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, PluginRequestOptions]]: + """按需生成仓库 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: Any, + releases: PluginReleaseList, + ) -> 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[PluginIndex]: + """读取插件索引;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[PluginIndex]: + """ + 获取 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") # type: ignore[misc] + def _get_plugin_repo_releases( + self, repo_url: str + ) -> Optional[PluginReleaseList]: + """ + 按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。 + """ + releases: PluginReleaseList = [] + 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 + ) -> PluginReleaseList: + """ + 获取插件可安装的 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("/"))) + + 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 + + @staticmethod + def _build_github_request_strategies( + url: str, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> list[tuple[str, str, PluginRequestOptions]]: + """构造同步与异步 GitHub 请求共用的镜像、代理和直连顺序。""" + strategies: list[tuple[str, str, PluginRequestOptions]] = [] + 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[str, str]] = 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 = PluginMarketTransport._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 + + @staticmethod + def request_with_fallback( + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Optional[Response]: + """向包来源客户端公开既有同步 GitHub 降级传输。""" + return PluginMarketTransport.__request_with_fallback( + url, headers=headers, timeout=timeout, is_api=is_api + ) + + def __get_plugin_meta( + self, pid: str, repo_url: str, package_version: Optional[str] + ) -> PluginPayload: + """读取远端插件元数据,供兼容性查询保留旧私有入口。""" + try: + plugins = self.get_plugins(repo_url, package_version or None) or {} + meta = plugins.get(pid) + return meta if isinstance(meta, dict) else {} + except Exception as error: + logger.error(f"获取插件 {pid} 元数据失败:{error}") + 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 + + 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[str, str]] = 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 = PluginMarketTransport._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 + + @staticmethod + async def async_request_with_fallback( + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Optional[httpx2.Response]: + """向包来源客户端公开既有异步 GitHub 降级传输。""" + return await PluginMarketTransport.__async_request_with_fallback( + url, headers=headers, timeout=timeout, is_api=is_api + ) + + async def __async_get_plugin_meta( + self, pid: str, repo_url: str, package_version: Optional[str] + ) -> PluginPayload: + """异步读取远端插件元数据,供兼容性查询保留旧私有入口。""" + try: + plugins = await self.async_get_plugins( + repo_url, package_version or None + ) or {} + meta = plugins.get(pid) + return meta if isinstance(meta, dict) else {} + except Exception as error: + logger.warning(f"获取插件 {pid} 元数据失败:{error}") + return {} + + @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[PluginIndex]: + """异步读取插件索引;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[PluginIndex]: + """ + 异步获取 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") # type: ignore[misc] + async def _async_get_plugin_repo_releases( + self, repo_url: str + ) -> Optional[PluginReleaseList]: + """ + 异步按仓库获取 GitHub Release 原始分页数据。 + """ + releases: PluginReleaseList = [] + 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 + ) -> PluginReleaseList: + """ + 异步获取插件可安装的 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 bool( + 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[PluginReleaseTask], + ) -> Optional[PluginReleaseList]: + """等待在途普通读取落盘后执行强刷,确保旧结果不会覆盖强刷缓存。""" + if pending_normal_task: + try: + await asyncio.shield(pending_normal_task) + except (Exception, asyncio.CancelledError): + pass + return cast( + Optional[PluginReleaseList], + await self._async_get_plugin_repo_releases(repo_url), + ) + + @classmethod + def _remove_release_task( + cls, + task_key: tuple[asyncio.AbstractEventLoop, str, bool], + task: PluginReleaseTask, + ) -> None: + """请求任务完成后释放事件循环和仓库引用。""" + with cls._release_task_lock: + if cls._release_tasks.get(task_key) is task: + cls._release_tasks.pop(task_key, None) class PluginMarketClient: """把插件市场、版本元数据和本地仓库查询隔离为只读客户端。""" - def __init__(self, helper: Optional[_PluginHelper] = None) -> None: - """复用旧 PluginHelper 实现,保持缓存和弱单例身份不变。""" - self._helper = helper or _PluginHelper() + def __init__(self, transport: Optional[PluginMarketTransport] = None) -> None: + """保存索引传输端口;默认实现必须先由兼容市场模块显式注册。""" + self._transport = transport or PluginMarketTransport() def get_plugins( self, repo_url: str, package_version: Optional[str] = None, force: bool = False, - ) -> Optional[dict[str, dict[str, Any]]]: + ) -> Optional[PluginIndex]: """同步读取指定仓库和代际的插件索引。""" with fresh(force): - return self._helper.get_plugins(repo_url, package_version) + return self._transport.get_plugins(repo_url, package_version) async def async_get_plugins( self, repo_url: str, package_version: Optional[str] = None, force: bool = False, - ) -> Optional[dict[str, dict]]: + ) -> Optional[PluginIndex]: """异步读取指定仓库和代际的插件索引。""" async with async_fresh(force): - return await self._helper.async_get_plugins(repo_url, package_version) + return await self._transport.async_get_plugins(repo_url, package_version) def get_plugin_index_result( self, repo_url: str, package_version: Optional[str] = None, force: bool = False, - ) -> Optional[dict[str, dict]]: + ) -> Optional[PluginIndex]: """读取插件索引的三态结果,供库存读取保留失败事实。""" with fresh(force): return cast( - Optional[dict[str, dict[str, Any]]], - self._helper.get_plugin_index_result(repo_url, package_version), + Optional[PluginIndex], + self._transport.get_plugin_index_result(repo_url, package_version), ) async def async_get_plugin_index_result( @@ -54,20 +1332,20 @@ class PluginMarketClient: repo_url: str, package_version: Optional[str] = None, force: bool = False, - ) -> Optional[dict[str, dict[str, Any]]]: + ) -> Optional[PluginIndex]: """异步读取插件索引的三态结果,供库存读取保留失败事实。""" async with async_fresh(force): return cast( - Optional[dict[str, dict[str, Any]]], - await self._helper.async_get_plugin_index_result( + Optional[PluginIndex], + await self._transport.async_get_plugin_index_result( repo_url, package_version, ), ) - def get_local_candidates(self) -> dict[str, dict]: + def get_local_candidates(self) -> PluginIndex: """返回全部本地插件仓库候选。""" - return self._helper.get_local_plugin_candidates() + return self._transport.get_local_plugin_candidates() def get_local_candidate( self, @@ -75,9 +1353,9 @@ class PluginMarketClient: package_version: Optional[str] = None, repo_path: Optional[Path] = None, **kwargs: Any, - ) -> Optional[dict]: + ) -> Optional[PluginPayload]: """返回指定插件的本地仓库候选。""" - return self._helper.get_local_plugin_candidate( + return self._transport.get_local_plugin_candidate( pid=plugin_id, package_version=package_version, repo_path=repo_path, @@ -87,7 +1365,20 @@ class PluginMarketClient: @staticmethod def get_local_repo_paths() -> list[Path]: """返回配置中有效的本地插件仓库目录。""" - return _PluginHelper.get_local_repo_paths() + value = get_runtime_setting('PLUGIN_LOCAL_REPO_PATHS') + if not value: + return [] + root_path = Path(get_runtime_setting('ROOT_PATH')) + paths: list[Path] = [] + for item in value.split(","): + local_repo_path = item.strip() + if not local_repo_path: + continue + path = Path(local_repo_path).expanduser() + if not path.is_absolute(): + path = root_path / path + paths.append(path.resolve()) + return paths @staticmethod def make_local_repo_url( @@ -96,29 +1387,214 @@ class PluginMarketClient: package_version: Optional[str] = None, ) -> str: """生成兼容旧入口的本地插件来源标识。""" - return _PluginHelper.make_local_repo_url( + return build_local_repo_url( plugin_id, - repo_path, - package_version, + repo_path=cast(Optional[Path], repo_path), + package_version=package_version, ) @staticmethod def is_local_repo_url(repo_url: Optional[str]) -> bool: """判断插件来源是否为本地仓库标识。""" - return _PluginHelper.is_local_repo_url(repo_url) + return is_local_plugin_source(repo_url) @staticmethod - def annotate_system_version(plugin_info: dict) -> dict: + def annotate_system_version(plugin_info: PluginPayload) -> PluginPayload: """补充插件所需 MoviePilot 版本兼容状态。""" - return _PluginHelper.annotate_plugin_system_version(plugin_info) + compatible, message = check_plugin_system_version( + plugin_info, + current_version=get_app_version(), + ) + plugin_info["system_version_compatible"] = compatible + plugin_info["system_version_message"] = message + return plugin_info @staticmethod def is_package_compatible( - plugin_info: dict, + plugin_info: PluginPayload, package_version: Optional[str], ) -> bool: """判断插件条目是否兼容目标插件包代际。""" - return _PluginHelper.is_package_plugin_compatible( + return is_plugin_generation_compatible( plugin_info, package_version, + current_generation=get_runtime_setting('VERSION_FLAG'), + free_threaded=is_free_threaded_runtime(), ) + + +class PluginPackageSourceClient: + """为系统包 owner 提供只读市场元数据和远端制品传输。""" + + def __init__(self, transport: Optional[PluginMarketTransport] = None) -> None: + """保存由组合根注入的市场传输,不导入兼容 Helper 实现。""" + self._transport = transport or PluginMarketTransport() + + @staticmethod + def is_local_repo_url(repo_url: Optional[str]) -> bool: + """判断来源是否为本地仓库标识。""" + return is_local_repo_url(repo_url) + + @staticmethod + def parse_local_repo_url(repo_url: str) -> Optional[str]: + """解析本地仓库来源中的插件标识。""" + return parse_local_repo_plugin_id(repo_url) + + @staticmethod + def parse_local_repo_path(repo_url: str) -> Optional[Path]: + """解析本地仓库来源中的物理路径。""" + root = get_runtime_setting('ROOT_PATH') + return parse_local_repo_path( + repo_url, + root_path=Path(root) if root is not None else None, + ) + + @staticmethod + def parse_local_repo_package_version(repo_url: str) -> Optional[str]: + """解析本地仓库来源中的包代际。""" + return parse_local_repo_generation(repo_url) + + @staticmethod + def make_local_repo_url( + plugin_id: str, + repo_path: Optional[Path] = None, + package_version: Optional[str] = None, + ) -> str: + """生成本地仓库来源标识。""" + return build_local_repo_url( + plugin_id, + repo_path=repo_path, + package_version=package_version, + ) + + 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[PluginPayload]: + """读取一个本地插件候选。""" + return self._transport.get_local_plugin_candidate( + pid=pid, + package_version=package_version, + repo_path=repo_path, + strict_compat=strict_compat, + strict_system_version=strict_system_version, + ) + + @staticmethod + def check_plugin_system_version( + plugin_info: PluginPayload, + ) -> tuple[bool, str]: + """校验插件声明的宿主版本约束。""" + return check_plugin_system_version( + plugin_info, current_version=get_app_version() + ) + + def get_plugin_package_version( + self, + plugin_id: str, + repo_url: str, + package_version: Optional[str], + ) -> Optional[str]: + """选择适用于当前宿主的远端索引代际。""" + return self._transport.get_plugin_package_version( + plugin_id, repo_url, package_version + ) + + async def async_get_plugin_package_version( + self, + plugin_id: str, + repo_url: str, + package_version: Optional[str], + ) -> Optional[str]: + """异步选择适用于当前宿主的远端索引代际。""" + return await self._transport.async_get_plugin_package_version( + plugin_id, repo_url, package_version + ) + + def get_plugins( + self, repo_url: str, package_version: Optional[str] = None + ) -> Optional[PluginIndex]: + """读取远端插件索引。""" + return self._transport.get_plugins(repo_url, package_version) + + async def async_get_plugins( + self, repo_url: str, package_version: Optional[str] = None + ) -> Optional[PluginIndex]: + """异步读取远端插件索引。""" + return await self._transport.async_get_plugins(repo_url, package_version) + + def get_plugin_release_versions( + self, plugin_id: str, repo_url: str + ) -> list[dict[str, Any]]: + """读取插件可安装 Release。""" + return self._transport.get_plugin_release_versions(plugin_id, repo_url) + + async def async_get_plugin_release_versions( + self, plugin_id: str, repo_url: str + ) -> list[dict[str, Any]]: + """异步读取插件可安装 Release。""" + return await self._transport.async_get_plugin_release_versions( + plugin_id, repo_url + ) + + @staticmethod + def get_repo_info(repo_url: str) -> tuple[Optional[str], Optional[str]]: + """解析 GitHub 仓库所有者和仓库名。""" + if not repo_url: + return None, None + parts = repo_url.rstrip("/").split("/") + if len(parts) < 5: + return None, None + return parts[-2], parts[-1] + + def request( + self, + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Any: + """同步读取 GitHub 元数据或制品。""" + return self._transport.request_with_fallback( + url, headers=headers, timeout=timeout, is_api=is_api + ) + + async def async_request( + self, + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Any: + """异步读取 GitHub 元数据或制品。""" + return await self._transport.async_request_with_fallback( + url, headers=headers, timeout=timeout, is_api=is_api + ) + + +setattr( + PluginMarketTransport.get_plugin_release_versions, + "cache_clear", + getattr(PluginMarketTransport._get_plugin_repo_releases, "cache_clear"), +) +setattr( + PluginMarketTransport.get_plugin_release_versions, + "cache_region", + getattr(PluginMarketTransport._get_plugin_repo_releases, "cache_region"), +) +setattr( + PluginMarketTransport.async_get_plugin_release_versions, + "cache_clear", + getattr(PluginMarketTransport._async_get_plugin_repo_releases, "cache_clear"), +) +setattr( + PluginMarketTransport.async_get_plugin_release_versions, + "cache_region", + getattr(PluginMarketTransport._async_get_plugin_repo_releases, "cache_region"), +) diff --git a/app/adapters/system/plugin/dependency.py b/app/adapters/system/plugin/dependency.py index 5be56151e..368182fdd 100644 --- a/app/adapters/system/plugin/dependency.py +++ b/app/adapters/system/plugin/dependency.py @@ -8,7 +8,7 @@ from collections.abc import Callable from dataclasses import dataclass, field from importlib.metadata import PackageNotFoundError, distribution, distributions from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Protocol from urllib.parse import urlsplit from packaging.markers import default_environment @@ -16,6 +16,7 @@ from packaging.requirements import Requirement from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import InvalidVersion, Version +from app.adapters.system.plugin.health import PluginRuntimeHealth from app.adapters.system.plugin.manifest import ( PluginDependencyManifestError, load_dependency_manifest, @@ -34,22 +35,38 @@ class _RequirementGroup: specifiers: set[str] = field(default_factory=set) # 待求交集的版本约束 +class PluginDependencyPackagePort(Protocol): + """声明依赖聚合器所需的唯一 Python 包安装边界。""" + + def install_packages_with_fallback( + self, + dependency_files: list[Path], + find_links_dirs: list[Path], + ) -> tuple[bool, str]: + """同步安装已经聚合并准入的依赖清单。""" + + async def async_install_packages_with_fallback( + self, + dependency_files: list[Path], + find_links_dirs: list[Path], + ) -> tuple[bool, str]: + """异步安装已经聚合并准入的依赖清单。""" + + class PluginDependencyInstaller: """独立负责插件依赖扫描、约束合并和安装。""" def __init__( self, - helper: Any = None, + packages: Optional[PluginDependencyPackagePort] = None, *, installed_plugins_provider: Optional[Callable[[], list[str]]] = None, plugin_dir: Optional[Path] = None, ) -> None: """保存包安装端口和启动层提供的已安装插件读取器。""" - if helper is None: - from app.adapters.external.market import PluginHelper - - helper = PluginHelper() - self._helper = helper + if packages is None: + packages = PluginRuntimeHealth() + self._packages = packages self._installed_plugins_provider = installed_plugins_provider or (lambda: []) self._plugin_dir = plugin_dir or ( Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" @@ -178,7 +195,7 @@ class PluginDependencyInstaller: requirement.marker.evaluate({**environment, "extra": extra}) for extra in extras ) - return requirement.marker.evaluate(environment) + return bool(requirement.marker.evaluate(environment)) @staticmethod def _standardize_extra(name: str) -> str: @@ -351,7 +368,7 @@ class PluginDependencyInstaller: manifest_paths = [manifest.path for manifest in self._plugin_manifests()] if not manifest_paths: return False, "没有找到已安装插件的依赖清单" - return self._helper.install_packages_with_fallback( + return self._packages.install_packages_with_fallback( manifest_paths, self._wheels_dirs(), ) @@ -371,7 +388,7 @@ class PluginDependencyInstaller: manifest_paths = [manifest.path for manifest in self._plugin_manifests()] if not manifest_paths: return False, "没有找到已安装插件的依赖清单" - return await self._helper.async_install_packages_with_fallback( + return await self._packages.async_install_packages_with_fallback( manifest_paths, self._wheels_dirs(), ) diff --git a/app/adapters/system/plugin/health.py b/app/adapters/system/plugin/health.py new file mode 100644 index 000000000..f196b12ef --- /dev/null +++ b/app/adapters/system/plugin/health.py @@ -0,0 +1,1060 @@ +"""插件依赖安装后的运行环境准入、健康检查与自动修复。""" + +from __future__ import annotations + +import asyncio +import importlib +import re +import site +import sys +import tempfile +import threading +from collections import deque +from importlib.metadata import distributions +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from packaging.markers import default_environment +from packaging.requirements import InvalidRequirement, Requirement +from packaging.specifiers import SpecifierSet +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +from app.adapters.system.host import SystemUtils +from app.adapters.system.package import ( + PackageInstallRequest, + build_package_install_strategies, + build_project_sync_strategies, + find_uv, +) +from app.adapters.system.plugin.manifest import load_dependency_file +from app.runtime.dependencies.profile import ( + iter_runtime_profile_requirement_strings, + iter_runtime_requirement_strings, + runtime_excluded_dependency_pairs, +) +from app.runtime.execution import await_task_to_terminal +from app.runtime.execution import ( + run_in_threadpool_to_completion as _await_thread_operation, +) +from app.runtime.log import logger +from app.runtime.settings import get_runtime_setting + + +class PluginRuntimeHealth: + """负责插件依赖写入的运行环境保护、检查和故障恢复。""" + + _package_install_lock = threading.Lock() + _protected_runtime_packages = frozenset({ + "alembic", "fastapi", "pydantic", "pydantic_core", + "pydantic_settings", "sqlalchemy", "starlette", "uvicorn", + }) + _runtime_import_probe = "app.doctor.dependencies" + PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 300 + + @classmethod + def __get_installed_packages(cls) -> Dict[str, Version]: + """ + 获取已安装的包及其版本 + 使用 importlib.metadata 获取当前环境中已安装的包,标准化包名并转换版本信息 + 对于无法解析的版本,记录警告日志并跳过 + :return: 已安装包的字典,格式为 {package_name: Version} + """ + installed_packages: Dict[str, Version] = {} + 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(".", "_") + + @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: Any, extra: str = "") -> bool: + """ + 使用当前运行环境和可选 extra 上下文判断 marker 是否生效。 + """ + if not marker: + return True + try: + env = default_environment() + env["extra"] = extra + return bool(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: Dict[str, Set[str]] = {} + 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: Dict[str, Tuple[Version, List[Requirement]]] = {} + 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, =", ">"): + 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() -> None: + """ + 依赖安装或修复后刷新当前解释器的导入缓存,保证后续动态导入能看到新状态。 + """ + 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 安装请求,统一缓存、镜像和代理语义。 + """ + resolved_dependency_files: tuple[Path, ...] + 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[^`]+)` requires `(?P[^`]+)`, " + 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 + + @classmethod + def __runtime_health_regression_message( + cls, + 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 = cls.__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 = cls.__runtime_health_error_lines( + check_name, + baseline_message, + ) + current_lines = cls.__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: (是否成功, 错误信息) + """ + resolved_dependency_files: tuple[Path, ...] + 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] 所有策略均安装依赖失败,请检查网络连接、包源配置或插件依赖约束" + + @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 子进程。""" + resolved_dependency_files: tuple[Path, ...] + 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, + ) diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index 683578fab..4d9e18857 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -2,16 +2,34 @@ from __future__ import annotations +import asyncio import hashlib +import io import re import shutil +import stat import uuid +import zipfile +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from pathlib import Path -from typing import Any, Optional, cast +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any, Optional, Protocol, Sequence, cast + +import aiofiles # type: ignore[import-untyped] +import aioshutil +from anyio import Path as AsyncPath -from app.adapters.external.market import PluginHelper as _PluginHelper from app.adapters.system.host import SystemUtils +from app.adapters.system.plugin.health import PluginRuntimeHealth +from app.adapters.system.plugin.manifest import ( + PluginDependencyManifestError, + dependency_manifest_declares_installation, + load_dependency_manifest, +) +from app.domain.plugin import ( + PluginReleaseInstallPlan, + build_plugin_release_install_plan, +) from app.runtime.dependencies.native import ( LoadedNativeDependencySnapshot, NativeDependencyChange, @@ -23,6 +41,99 @@ from app.runtime.execution import ( ) from app.runtime.log import logger from app.runtime.settings import get_runtime_setting +from app.runtime.version import get_app_version + + +class PluginPackageSourcePort(Protocol): + """声明包 owner 读取市场元数据和远端制品所需的外部端口。""" + + def is_local_repo_url(self, repo_url: Optional[str]) -> bool: + """判断来源是否为本地仓库。""" + + def parse_local_repo_url(self, repo_url: str) -> Optional[str]: + """解析本地来源中的插件标识。""" + + def parse_local_repo_path(self, repo_url: str) -> Optional[Path]: + """解析本地来源中的仓库目录。""" + + def parse_local_repo_package_version(self, repo_url: str) -> Optional[str]: + """解析本地来源中的包代际。""" + + def make_local_repo_url( + self, + plugin_id: str, + repo_path: Optional[Path] = None, + package_version: Optional[str] = None, + ) -> str: + """生成本地仓库来源标识。""" + + 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[str, Any]]: + """读取一个本地插件候选。""" + + def check_plugin_system_version( + self, plugin_info: dict[str, Any] + ) -> tuple[bool, str]: + """校验插件声明的宿主版本约束。""" + + def get_plugin_package_version( + self, plugin_id: str, repo_url: str, package_version: Optional[str] + ) -> Optional[str]: + """选择适用于当前宿主的远端索引代际。""" + + async def async_get_plugin_package_version( + self, plugin_id: str, repo_url: str, package_version: Optional[str] + ) -> Optional[str]: + """异步选择适用于当前宿主的远端索引代际。""" + + def get_plugins( + self, repo_url: str, package_version: Optional[str] = None + ) -> Optional[dict[str, dict[str, Any]]]: + """读取远端插件索引。""" + + async def async_get_plugins( + self, repo_url: str, package_version: Optional[str] = None + ) -> Optional[dict[str, dict[str, Any]]]: + """异步读取远端插件索引。""" + + def get_plugin_release_versions( + self, plugin_id: str, repo_url: str + ) -> list[dict[str, Any]]: + """读取插件可安装 Release。""" + + async def async_get_plugin_release_versions( + self, plugin_id: str, repo_url: str + ) -> list[dict[str, Any]]: + """异步读取插件可安装 Release。""" + + def get_repo_info(self, repo_url: str) -> tuple[Optional[str], Optional[str]]: + """解析 GitHub 仓库所有者和仓库名。""" + + def request( + self, + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Any: + """同步读取 GitHub 元数据或制品。""" + + async def async_request( + self, + url: str, + *, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Any: + """异步读取 GitHub 元数据或制品。""" @dataclass(slots=True) @@ -55,16 +166,33 @@ class PluginPackageManager: _COPY_IGNORE = ("__pycache__", "*.pyc", ".DS_Store", "node_modules") - def __init__(self, helper: Optional[_PluginHelper] = None) -> None: - """保存市场下载实现;文件事务由本适配器独立负责。""" - self._helper = helper or _PluginHelper() + def __init__( + self, + source: Optional[PluginPackageSourcePort] = None, + *, + health: Optional[PluginRuntimeHealth] = None, + plugin_root: Optional[Path] = None, + ) -> None: + """保存外部来源端口和依赖健康 owner。""" + self._source = source + self._health = health or PluginRuntimeHealth() + self._plugin_root = plugin_root.resolve() if plugin_root else None - @staticmethod - def __plugin_dir(plugin_id: str) -> Path: - """解析插件运行目录并拒绝越出宿主插件根目录的标识。""" - plugins_root = ( + def _require_source(self) -> PluginPackageSourcePort: + """返回已装配来源端口,未完成组合时拒绝执行包写入。""" + if self._source is None: + raise RuntimeError("插件包来源客户端尚未完成装配") + return self._source + + def _plugins_root(self) -> Path: + """返回组合根注入目录;旧直接构造调用按当前运行配置解析。""" + return self._plugin_root or ( Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins" ).resolve() + + def __plugin_dir(self, plugin_id: str) -> Path: + """解析插件运行目录并拒绝越出宿主插件根目录的标识。""" + plugins_root = self._plugins_root() plugin_dir = (plugins_root / plugin_id.lower()).resolve() if plugin_dir == plugins_root or not plugin_dir.is_relative_to(plugins_root): raise ValueError(f"非法插件ID:{plugin_id}") @@ -203,9 +331,7 @@ class PluginPackageManager: if checkpoint.native_dependencies is None: return () try: - return detect_changed_native_dependencies( - checkpoint.native_dependencies - ) + return detect_changed_native_dependencies(checkpoint.native_dependencies) except Exception as error: # noqa: BLE001 - 诊断失败不能改写安装结果 logger.warning("检测插件原生依赖变更失败:%s", error) return () @@ -493,6 +619,167 @@ class PluginPackageManager: for part in relative_parts ) + def is_local_repo_url(self, repo_url: Optional[str]) -> bool: + """委托外部来源客户端判断本地仓库标识。""" + return self._require_source().is_local_repo_url(repo_url) + + def parse_local_repo_url(self, repo_url: str) -> Optional[str]: + """委托外部来源客户端解析本地插件标识。""" + return self._require_source().parse_local_repo_url(repo_url) + + def parse_local_repo_path(self, repo_url: str) -> Optional[Path]: + """委托外部来源客户端解析本地仓库路径。""" + return self._require_source().parse_local_repo_path(repo_url) + + def parse_local_repo_package_version(self, repo_url: str) -> Optional[str]: + """委托外部来源客户端解析本地包代际。""" + return self._require_source().parse_local_repo_package_version(repo_url) + + def make_local_repo_url( + self, + plugin_id: str, + repo_path: Optional[Path] = None, + package_version: Optional[str] = None, + ) -> str: + """委托外部来源客户端生成本地来源标识。""" + return self._require_source().make_local_repo_url( + plugin_id, repo_path, package_version + ) + + 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[str, Any]]: + """委托外部来源客户端读取本地插件候选。""" + return self._require_source().get_local_plugin_candidate( + pid=pid, + package_version=package_version, + repo_path=repo_path, + strict_compat=strict_compat, + strict_system_version=strict_system_version, + ) + + def check_plugin_system_version( + self, plugin_info: dict[str, Any] + ) -> tuple[bool, str]: + """委托外部来源客户端校验宿主版本约束。""" + return self._require_source().check_plugin_system_version(plugin_info) + + def get_plugin_package_version( + self, plugin_id: str, repo_url: str, package_version: Optional[str] + ) -> Optional[str]: + """委托外部来源客户端选择远端索引代际。""" + return self._require_source().get_plugin_package_version( + plugin_id, repo_url, package_version + ) + + async def async_get_plugin_package_version( + self, plugin_id: str, repo_url: str, package_version: Optional[str] + ) -> Optional[str]: + """异步委托外部来源客户端选择远端索引代际。""" + return await self._require_source().async_get_plugin_package_version( + plugin_id, repo_url, package_version + ) + + def get_plugins( + self, repo_url: str, package_version: Optional[str] = None + ) -> Optional[dict[str, dict[str, Any]]]: + """委托外部来源客户端读取远端插件索引。""" + return self._require_source().get_plugins(repo_url, package_version) + + async def async_get_plugins( + self, repo_url: str, package_version: Optional[str] = None + ) -> Optional[dict[str, dict[str, Any]]]: + """异步委托外部来源客户端读取远端插件索引。""" + return await self._require_source().async_get_plugins( + repo_url, package_version + ) + + def get_plugin_release_versions( + self, plugin_id: str, repo_url: str + ) -> list[dict[str, Any]]: + """委托外部来源客户端读取可安装 Release。""" + return self._require_source().get_plugin_release_versions( + plugin_id, repo_url + ) + + async def async_get_plugin_release_versions( + self, plugin_id: str, repo_url: str + ) -> list[dict[str, Any]]: + """异步委托外部来源客户端读取可安装 Release。""" + return await self._require_source().async_get_plugin_release_versions( + plugin_id, repo_url + ) + + def get_repo_info(self, repo_url: str) -> tuple[Optional[str], Optional[str]]: + """委托外部来源客户端解析 GitHub 仓库信息。""" + return self._require_source().get_repo_info(repo_url) + + def __request_with_fallback( + self, + url: str, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Any: + """通过外部来源客户端同步读取远端内容。""" + return self._require_source().request( + url, headers=headers, timeout=timeout, is_api=is_api + ) + + async def __async_request_with_fallback( + self, + url: str, + headers: Optional[dict[str, str]] = None, + timeout: Optional[int] = 60, + is_api: bool = False, + ) -> Any: + """通过外部来源客户端异步读取远端内容。""" + return await self._require_source().async_request( + url, headers=headers, timeout=timeout, is_api=is_api + ) + + @staticmethod + def _build_remote_plugin_install_plan( + *, + pid: str, + meta: dict[str, Any], + release_version: Optional[str] = None, + release_items: Sequence[dict[str, Any]] = (), + ) -> tuple[Optional[PluginReleaseInstallPlan], str]: + """使用纯领域规则选择 Release 或文件列表安装计划。""" + return build_plugin_release_install_plan( + plugin_id=pid, + metadata=meta, + release_version=release_version, + release_items=release_items, + current_version=get_app_version(), + ) + + def install_packages_with_fallback( + self, + dependency_files: Path | Sequence[Path], + find_links_dirs: Optional[list[Path]] = None, + ) -> tuple[bool, str]: + """把插件依赖清单交给运行环境健康 owner 安装。""" + return self._health.install_packages_with_fallback( + dependency_files, find_links_dirs + ) + + async def async_install_packages_with_fallback( + self, + dependency_files: Path | Sequence[Path], + find_links_dirs: Optional[list[Path]] = None, + ) -> tuple[bool, str]: + """异步把插件依赖清单交给运行环境健康 owner 安装。""" + return await self._health.async_install_packages_with_fallback( + dependency_files, find_links_dirs + ) + def install( self, plugin_id: str, @@ -502,20 +789,17 @@ class PluginPackageManager: force_install: bool = False, checkpoint: PluginPackageCheckpoint | None = None, ) -> tuple[bool, str]: - """同步安装插件包,下载过程继续复用既有市场兼容策略。""" - return cast( - tuple[bool, str], - cast(Any, self._helper)._PluginHelper__install_package( - pid=plugin_id, - repo_url=repo_url, - package_version=package_version, - release_version=release_version, - force_install=force_install, - before_dependency_install=( - (lambda: self.__capture_native_dependencies(checkpoint)) - if checkpoint is not None - else None - ), + """同步安装插件包;已有事务快照时禁止兼容层再创建第二份备份。""" + return self.install_raw( + pid=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force_install=force_install or checkpoint is not None, + before_dependency_install=( + (lambda: self.__capture_native_dependencies(checkpoint)) + if checkpoint is not None + else None ), ) @@ -528,20 +812,17 @@ class PluginPackageManager: force_install: bool = False, checkpoint: PluginPackageCheckpoint | None = None, ) -> tuple[bool, str]: - """异步安装插件包,下载过程继续复用既有市场兼容策略。""" - return cast( - tuple[bool, str], - await cast(Any, self._helper)._PluginHelper__async_install_package( - pid=plugin_id, - repo_url=repo_url, - package_version=package_version, - release_version=release_version, - force_install=force_install, - before_dependency_install=( - (lambda: self.__capture_native_dependencies(checkpoint)) - if checkpoint is not None - else None - ), + """异步安装插件包;已有事务快照时禁止兼容层再创建第二份备份。""" + return await self.async_install_raw( + pid=plugin_id, + repo_url=repo_url, + package_version=package_version, + release_version=release_version, + force_install=force_install or checkpoint is not None, + before_dependency_install=( + (lambda: self.__capture_native_dependencies(checkpoint)) + if checkpoint is not None + else None ), ) @@ -557,6 +838,1249 @@ class PluginPackageManager: except Exception as error: # noqa: BLE001 - 诊断失败不能阻断插件安装 logger.warning("记录插件原生依赖安装前状态失败:%s", error) + def install_raw(self, pid: str, repo_url: str, package_version: Optional[str] = None, + release_version: Optional[str] = None, force_install: bool = False, + before_dependency_install: Optional[Callable[[], None]] = None) \ + -> 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, + before_dependency_install=before_dependency_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, + before_dependency_install, + ) + + 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, + before_dependency_install, + ) + # 未声明 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, + before_dependency_install, + ) + + def install_local_raw( + self, + plugin_id: str, + repo_url: str = "", + force_install: bool = False, + before_dependency_install: Optional[Callable[[], None]] = None, + ) -> tuple[bool, str]: + """提供给兼容 Facade 的本地载荷安装入口。""" + return self.__install_local_package( + pid=plugin_id, + repo_url=repo_url, + force_install=force_install, + before_dependency_install=before_dependency_install, + ) + + def backup_plugin(self, plugin_id: str) -> Optional[str]: + """提供给兼容 Facade 的临时备份入口。""" + return self.__backup_plugin(plugin_id) + + def restore_plugin(self, plugin_id: str, backup_dir: str) -> None: + """提供给兼容 Facade 的临时备份恢复入口。""" + self.__restore_plugin(plugin_id, backup_dir) + + def remove_plugin(self, plugin_id: str) -> bool: + """删除受控插件目录,返回删除前是否存在。""" + plugin_dir = self.__plugin_dir(plugin_id) + if not plugin_dir.exists(): + return False + shutil.rmtree(plugin_dir, ignore_errors=False) + return True + + def install_from_release( + self, plugin_id: str, user_repo: str, release_tag: str + ) -> tuple[bool, str]: + """提供给兼容 Facade 的 Release 制品安装入口。""" + return self.__install_from_release(plugin_id, user_repo, release_tag) + + def __install_local_package( + self, + pid: str, + repo_url: str = "", + force_install: bool = False, + before_dependency_install: Optional[Callable[[], None]] = None, + ) -> 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 + + raw_source_path = candidate.get("path") + if not isinstance(raw_source_path, (str, Path)): + return False, "本地插件来源路径无效" + source_dir = Path(raw_source_path) + dest_dir = self._plugins_root() / 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, + ( + Path(repo_path_value) + if isinstance( + (repo_path_value := candidate.get("repo_path")), + (str, Path), + ) + else None + ), + ( + package_generation + if isinstance( + (package_generation := candidate.get("package_version")), + str, + ) + else None + ), + ), + before_dependency_install=before_dependency_install, + ) + + def __get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \ + tuple[Optional[list[dict[str, Any]]], 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 __resolve_download_file( + self, + pid: str, + remote_path: object, + package_version: Optional[str], + ) -> Path: + """把市场文件路径限定到当前插件目录,拒绝绝对路径和目录穿越。""" + if not isinstance(remote_path, str) or not remote_path or "\\" in remote_path: + raise ValueError("插件文件路径无效") + pure_path = PurePosixPath(remote_path) + if pure_path.is_absolute() or PureWindowsPath(remote_path).is_absolute(): + raise ValueError("插件文件路径无效") + parts = pure_path.parts + expected_root = f"plugins.{package_version}" if package_version else "plugins" + if ( + len(parts) < 3 + or parts[0] != expected_root + or parts[1].casefold() != pid.casefold() + or any(part in {"", ".", ".."} for part in parts[2:]) + ): + raise ValueError("插件文件路径无效") + plugin_dir = (self._plugins_root() / pid.lower()).resolve() + file_path = (plugin_dir / Path(*parts[2:])).resolve() + if not file_path.is_relative_to(plugin_dir): + raise ValueError("插件文件路径无效") + return file_path + + def __download_files(self, pid: str, file_list: list[dict[str, Any]], 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: + download_url = item.get("download_url") + if isinstance(download_url, str) and download_url: + try: + file_path = self.__resolve_download_file( + pid, + item.get("path"), + package_version, + ) + except ValueError as error: + return False, str(error) + logger.debug(f"正在下载文件:{item.get('path')}") + res = self.__request_with_fallback(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}" + + # 创建插件文件夹并写入文件 + 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: + # 如果是子目录,则将子目录内容加入栈中继续处理 + directory_name = item.get("name") + if ( + not isinstance(directory_name, str) + or directory_name in {"", ".", ".."} + or PurePosixPath(directory_name).name != directory_name + or PureWindowsPath(directory_name).name != directory_name + ): + return False, "插件目录路径无效" + sub_list, msg = self.__get_file_list(f"{current_pid}/{directory_name}", user_repo, + package_version) + if not sub_list: + return False, msg or "插件子目录读取失败" + stack.append((f"{current_pid}/{directory_name}", sub_list)) + + return True, "" + + def __install_dependencies_if_required( + self, + pid: str, + before_dependency_install: Optional[Callable[[], None]] = None, + ) -> tuple[bool, bool, str]: + """ + 安装插件依赖。 + :param pid: 插件 ID + :return: (是否存在依赖,安装是否成功, 错误信息) + """ + plugin_dir = self._plugins_root() / 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: + if ( + before_dependency_install is not None + and dependency_manifest_declares_installation(manifest) + ): + try: + before_dependency_install() + except Exception as error: # noqa: BLE001 - 观察失败不能阻断安装 + logger.warning(f"{pid} 依赖安装前状态记录失败:{error}") + 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, "不存在依赖" + + def __backup_plugin(self, pid: str) -> Optional[str]: + """ + 备份旧插件目录 + :param pid: 插件 ID + :return: 备份目录路径 + """ + plugin_dir = self._plugins_root() / 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 + + def __restore_plugin(self, pid: str, backup_dir: str) -> None: + """ + 还原旧插件目录 + :param pid: 插件 ID + :param backup_dir: 备份目录路径 + """ + plugin_dir = self._plugins_root() / 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}") + + def __remove_old_plugin(self, pid: str) -> None: + """ + 删除旧插件 + :param pid: 插件 ID + """ + plugin_dir = self._plugins_root() / pid.lower() + if plugin_dir.exists(): + shutil.rmtree(plugin_dir, ignore_errors=True) + + def refresh_persistent_backup(self, pid: str) -> bool: + """ + 刷新插件持久化备份目录,供 docker 重置后恢复使用 + """ + if not SystemUtils.is_docker(): + return True + + plugin_dir = self._plugins_root() / 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 __get_plugin_meta(self, pid: str, repo_url: str, + package_version: Optional[str]) -> dict[str, Any]: + """读取远端插件元数据,并把异常收敛为空映射。""" + 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 __install_flow_sync( + self, + pid: str, + force_install: bool, + prepare_content: Callable[[], tuple[bool, str]], + repo_url: Optional[str] = None, + before_dependency_install: Optional[Callable[[], None]] = 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, + before_dependency_install, + ) + if before_dependency_install is not None + else 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 = self._plugins_root() / 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}" + + async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \ + tuple[Optional[list[dict[str, Any]]], 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[str, Any]], 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: + download_url = item.get("download_url") + if isinstance(download_url, str) and download_url: + try: + resolved_path = self.__resolve_download_file( + pid, + item.get("path"), + package_version, + ) + except ValueError as error: + return False, str(error) + logger.debug(f"正在下载文件:{item.get('path')}") + res = await self.__async_request_with_fallback(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}" + + # 创建插件文件夹并写入文件 + file_path = AsyncPath(resolved_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: + # 如果是子目录,则将子目录内容加入栈中继续处理 + directory_name = item.get("name") + if ( + not isinstance(directory_name, str) + or directory_name in {"", ".", ".."} + or PurePosixPath(directory_name).name != directory_name + or PureWindowsPath(directory_name).name != directory_name + ): + return False, "插件目录路径无效" + sub_list, msg = await self.__async_get_file_list(f"{current_pid}/{directory_name}", user_repo, + package_version) + if not sub_list: + return False, msg or "插件子目录读取失败" + stack.append((f"{current_pid}/{directory_name}", sub_list)) + + return True, "" + + async def __async_backup_plugin(self, pid: str) -> Optional[str]: + """ + 异步备份旧插件目录 + :param pid: 插件 ID + :return: 备份目录路径 + """ + plugin_dir = AsyncPath(self._plugins_root()) / 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) -> None: + """ + 异步还原旧插件目录 + :param pid: 插件 ID + :param backup_dir: 备份目录路径 + """ + plugin_dir = AsyncPath(self._plugins_root()) / 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}") + + async def __async_remove_old_plugin(self, pid: str) -> None: + """ + 异步删除旧插件 + :param pid: 插件 ID + """ + plugin_dir = AsyncPath(self._plugins_root()) / pid.lower() + if await plugin_dir.exists(): + await aioshutil.rmtree(plugin_dir, ignore_errors=True) + + async def _async_copytree(self, src: AsyncPath, dst: AsyncPath) -> None: + """ + 异步递归复制目录 + :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, + before_dependency_install: Optional[Callable[[], None]] = None, + ) -> tuple[bool, bool, str]: + """ + 异步安装插件依赖。 + :param pid: 插件 ID + :return: (是否存在依赖,安装是否成功, 错误信息) + """ + plugin_dir = self._plugins_root() / 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: + if ( + before_dependency_install is not None + and dependency_manifest_declares_installation(manifest) + ): + try: + await _await_thread_operation(before_dependency_install) + except Exception as error: # noqa: BLE001 - 观察失败不能阻断安装 + logger.warning(f"{pid} 依赖安装前状态记录失败:{error}") + 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_raw( + self, + pid: str, + repo_url: str, + package_version: Optional[str] = None, + release_version: Optional[str] = None, + force_install: bool = False, + before_dependency_install: Optional[Callable[[], None]] = None, + ) -> tuple[bool, str]: + """执行已通过来源准入的异步包安装,不负责身份或运行态提交。""" + if self.is_local_repo_url(repo_url): + return cast( + tuple[bool, str], + await _await_thread_operation( + self.__install_local_package, + pid, + repo_url, + force_install, + before_dependency_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, + before_dependency_install, + ) + + 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, + before_dependency_install, + ) + # 未声明 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, + before_dependency_install, + ) + + async def async_install_local_raw( + self, + plugin_id: str, + repo_url: str = "", + force_install: bool = False, + before_dependency_install: Optional[Callable[[], None]] = None, + ) -> tuple[bool, str]: + """提供给兼容 Facade 的异步本地载荷安装入口。""" + return await self.async_install_raw( + pid=plugin_id, + repo_url=repo_url or self.make_local_repo_url(plugin_id), + force_install=force_install, + before_dependency_install=before_dependency_install, + ) + + async def async_backup_plugin(self, plugin_id: str) -> Optional[str]: + """提供给兼容 Facade 的异步临时备份入口。""" + return await self.__async_backup_plugin(plugin_id) + + async def async_restore_plugin( + self, plugin_id: str, backup_dir: str + ) -> None: + """提供给兼容 Facade 的异步临时备份恢复入口。""" + await self.__async_restore_plugin(plugin_id, backup_dir) + + async def async_remove_plugin(self, plugin_id: str) -> None: + """提供给兼容 Facade 的异步旧载荷清理入口。""" + await self.__async_remove_old_plugin(plugin_id) + + async def async_install_from_release( + self, plugin_id: str, user_repo: str, release_tag: str + ) -> tuple[bool, str]: + """提供给兼容 Facade 的异步 Release 制品安装入口。""" + return await self.__async_install_from_release( + plugin_id, user_repo, release_tag + ) + + async def __async_get_plugin_meta(self, pid: str, repo_url: str, + package_version: Optional[str]) -> dict[str, Any]: + """异步读取远端插件元数据,并把异常收敛为空映射。""" + 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, + before_dependency_install: Optional[Callable[[], None]] = 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, + before_dependency_install, + ) + if before_dependency_install is not None + else 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 or "插件文件列表读取失败" + 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 or "插件文件列表读取失败" + 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 = self._plugins_root() / 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() + def sync_local(self, plugin_id: str, source_dir: Path) -> bool: """用本地仓库内容原子替换运行副本,失败时恢复原目录。""" source_dir = source_dir.resolve() diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 5a358d424..a942c1afd 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -1,15 +1,12 @@ """插件 Agent 工具共享辅助方法""" import json -import shutil -from pathlib import Path from typing import Any, Optional -from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin.client import is_local_repo_url from app.application.configuration import get_configured_system_config from app.application.plugin.gateway import get_plugin_install_service from app.application.plugin.runtime import get_plugin_manager -from app.runtime.settings import get_runtime_setting from app.schemas.plugin import PluginRuntimeStatus from app.schemas.types import SystemConfigKey @@ -23,17 +20,6 @@ DEFAULT_PLUGIN_CANDIDATE_LIMIT = 50 MAX_PLUGIN_CANDIDATE_LIMIT = 200 -def _remove_plugin_directory(path: Path) -> bool: - """删除插件目录并返回是否完成,供受控线程执行。""" - if not path.exists(): - return False - try: - shutil.rmtree(path) - except Exception: - return False - return True - - def get_plugin_snapshot(plugin_id: str) -> Optional[dict[str, Any]]: """ 获取已安装插件的基础信息快照。 @@ -118,7 +104,7 @@ def summarize_plugin(plugin: Any) -> dict[str, Any]: "system_version_message": getattr(plugin, "system_version_message", None), "state": bool(getattr(plugin, "state", False)), "repo_url": repo_url, - "source": "local_repo" if PluginHelper.is_local_repo_url(repo_url) else "market", + "source": "local_repo" if is_local_repo_url(repo_url) else "market", } @@ -394,12 +380,11 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: elif was_clone: plugin_manager.delete_plugin_config(plugin_id) plugin_manager.delete_plugin_data(plugin_id) - plugin_base_dir = get_runtime_setting('ROOT_PATH') / "app" / "plugins" / plugin_id.lower() try: clone_files_removed = await run_agent_blocking( "plugin", - _remove_plugin_directory, - plugin_base_dir, + plugin_manager.remove_plugin_package, + plugin_id, ) if clone_files_removed: plugin_manager.plugins.pop(plugin_id, None) diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index b11ed4ed9..b1da973bb 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,6 +1,5 @@ import asyncio import mimetypes -import shutil from typing import Annotated, Any, Dict, List, Optional import aiofiles @@ -972,21 +971,9 @@ def uninstall_plugin( # 如果是分身插件,则删除分身数据和配置 plugin_manager.delete_plugin_config(plugin_id) plugin_manager.delete_plugin_data(plugin_id) - # 删除分身文件 - plugin_base_dir = ( - get_api_runtime_config_snapshot().root_path - / "app" - / "plugins" - / plugin_id.lower() - ) - if plugin_base_dir.exists(): - try: - shutil.rmtree(plugin_base_dir) - plugin_manager.plugins.pop(plugin_id, None) - except Exception as e: - logger.error( - f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}" - ) + # 分身物理目录只能由包文件 owner 删除。 + if plugin_manager.remove_plugin_package(plugin_id): + plugin_manager.plugins.pop(plugin_id, None) # 从插件文件夹中移除该插件 remove_plugin_from_folders(plugin_id) # 移除插件 diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 7f17e0367..b4d630cfa 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -16,7 +16,7 @@ from anyio import Path as AsyncPath from fastapi import Body, Depends, Header, HTTPException, Request, Response from fastapi.responses import StreamingResponse -from app.adapters.external.market import ( +from app.adapters.external.plugin.client import ( PLUGIN_MARKET_WIKI_URL, extract_plugin_market_repos_from_wiki, merge_plugin_market_repos, diff --git a/app/application/plugin/admission.py b/app/application/plugin/admission.py index 8d5658781..cbcb09de0 100644 --- a/app/application/plugin/admission.py +++ b/app/application/plugin/admission.py @@ -18,9 +18,9 @@ from app.application.plugin.source import ( CandidateInventory, PluginLocalCandidate, PluginSelectionStatus, - parse_local_plugin_reference, select_plugin_candidate, ) +from app.domain.plugin import parse_local_plugin_reference class PluginSourceAdmissionError(RuntimeError): diff --git a/app/application/plugin/inventory.py b/app/application/plugin/inventory.py index d6bb2925f..d4643b3be 100644 --- a/app/application/plugin/inventory.py +++ b/app/application/plugin/inventory.py @@ -20,6 +20,7 @@ from app.application.plugin.source import ( PluginMarketCandidate, normalize_package_generation, ) +from app.domain.plugin import is_plugin_generation_compatible from app.foundation.environment import is_free_threaded_runtime PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1") @@ -353,7 +354,12 @@ def _market_candidates( plugin_id = raw_info.get("id") or index_plugin_id if not isinstance(plugin_id, str): continue - if not _is_v3_compatible(raw_info, package_generation): + if not is_plugin_generation_compatible( + dict(raw_info), + None if package_generation == "v1" else package_generation, + current_generation="v3", + free_threaded=is_free_threaded_runtime(), + ): continue plugin_version = raw_info.get("version") if plugin_version is None: @@ -377,20 +383,6 @@ def _market_candidates( return tuple(result) -def _is_v3_compatible( - plugin_info: Mapping[str, Any], - package_generation: str, -) -> bool: - """按宿主 V3、兼容 V2、基础索引顺序判断候选兼容性。""" - if plugin_info.get("v3") is False: - return False - if is_free_threaded_runtime() and plugin_info.get("v3t") is False: - return False - if package_generation in {"v3", "v2"}: - return True - return plugin_info.get("v3") is True or plugin_info.get("v2") is True - - def _local_candidate( plugin_id: str, plugin_info: Mapping[str, Any], @@ -403,7 +395,12 @@ def _local_candidate( or "v1" ) ) - if not _is_v3_compatible(plugin_info, generation): + if not is_plugin_generation_compatible( + dict(plugin_info), + None if generation == "v1" else generation, + current_generation="v3", + free_threaded=is_free_threaded_runtime(), + ): return None repo_url = plugin_info.get("repo_url") if not isinstance(repo_url, str) or not repo_url.startswith("local://"): diff --git a/app/application/plugin/source.py b/app/application/plugin/source.py index c1995a6d0..2a877e6b8 100644 --- a/app/application/plugin/source.py +++ b/app/application/plugin/source.py @@ -6,7 +6,6 @@ from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from enum import StrEnum from typing import Any, TypeAlias -from urllib.parse import unquote, urlsplit from app.application.plugin.identity import ( PluginIdentity, @@ -825,18 +824,6 @@ def get_effective_local_candidate( return candidate if isinstance(candidate, PluginLocalCandidate) else None -def parse_local_plugin_reference(repo_url: str) -> str | None: - """从不透明本地来源标识中提取插件 ID,不读取或暴露宿主路径。""" - if not str(repo_url).startswith("local://"): - return None - try: - parsed = urlsplit(repo_url) - plugin_id = unquote(parsed.netloc or parsed.path.strip("/")) - except (TypeError, ValueError): - return None - return plugin_id or None - - def _coerce_online_source_type(source_type: TrustedPluginSourceType) -> TrustedPluginSourceType: """把外部字符串来源类型转换为可信在线来源枚举。""" value = TrustedPluginSourceType(source_type) diff --git a/app/domain/plugin.py b/app/domain/plugin.py new file mode 100644 index 000000000..6d8094403 --- /dev/null +++ b/app/domain/plugin.py @@ -0,0 +1,228 @@ +"""插件来源、代际与安装计划的纯领域规则。""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any +from urllib.parse import parse_qs, quote, unquote, urlsplit + +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version + +LOCAL_PLUGIN_SOURCE_PREFIX = "local://" +PLUGIN_SYSTEM_VERSION_FIELD = "system_version" +PLUGIN_GENERATION_COMPATIBILITY: dict[str, tuple[str, ...]] = { + "v3": ("v2",), +} + + +@dataclass(frozen=True, slots=True) +class PluginReleaseInstallPlan: + """描述远端插件内容准备模式,不持有同步或异步 I/O 实现。""" + + release_tag: str | None + fallback_to_filelist: bool + + +def is_local_plugin_source(repo_url: str | None) -> bool: + """判断插件来源是否为宿主定义的不透明本地仓库标识。""" + return bool(repo_url and repo_url.startswith(LOCAL_PLUGIN_SOURCE_PREFIX)) + + +def build_local_plugin_source( + plugin_id: str, + *, + repo_path: str | None = None, + package_generation: str | None = None, +) -> str: + """生成本地插件来源标识,不解释或访问其中的宿主路径。""" + repo_url = f"{LOCAL_PLUGIN_SOURCE_PREFIX}{quote(plugin_id, safe='')}" + parameters: list[str] = [] + if repo_path: + parameters.append(f"path={quote(repo_path, safe='/:~')}") + if package_generation: + parameters.append(f"version={quote(package_generation, safe='')}") + return f"{repo_url}?{'&'.join(parameters)}" if parameters else repo_url + + +def parse_local_plugin_reference(repo_url: str) -> str | None: + """从不透明本地来源标识中提取插件 ID。""" + if not is_local_plugin_source(repo_url): + return None + try: + parsed = urlsplit(repo_url) + plugin_id = unquote(parsed.netloc or parsed.path.strip("/")) + except (TypeError, ValueError): + return None + return plugin_id or None + + +def parse_local_plugin_path(repo_url: str) -> str | None: + """从本地来源标识中提取未解释的仓库路径字符串。""" + if not is_local_plugin_source(repo_url): + return None + try: + values = parse_qs(urlsplit(repo_url).query).get("path") + except (TypeError, ValueError): + return None + return values[0] if values else None + + +def parse_local_plugin_generation(repo_url: str) -> str | None: + """从本地来源标识中提取 package 代际。""" + if not is_local_plugin_source(repo_url): + return None + try: + values = parse_qs(urlsplit(repo_url).query).get("version") + except (TypeError, ValueError): + return None + return values[0] if values else None + + +def compatible_plugin_generations(current_generation: str | None) -> tuple[str, ...]: + """返回当前宿主允许读取的插件代际,按优先级去重。""" + if not current_generation: + return () + return tuple( + dict.fromkeys( + ( + current_generation, + *PLUGIN_GENERATION_COMPATIBILITY.get(current_generation, ()), + ) + ) + ) + + +def plugin_generation_candidates( + requested_generation: str | None, + *, + current_generation: str | None, +) -> tuple[str, ...]: + """返回安装读取唯一的代际候选顺序,并把基础索引固定在末尾。""" + preferred_generation = requested_generation or current_generation + candidates = [preferred_generation] + candidates.extend( + PLUGIN_GENERATION_COMPATIBILITY.get(preferred_generation or "", ()) + ) + candidates.append("") + return tuple(dict.fromkeys(candidate or "" for candidate in candidates)) + + +def is_plugin_generation_compatible( + plugin_info: object, + package_generation: str | None, + *, + current_generation: str | None, + free_threaded: bool, +) -> bool: + """按当前代际、兼容代际和基础索引规则判断一个市场条目。""" + if not isinstance(plugin_info, Mapping): + return False + if free_threaded and plugin_info.get("v3t") is False: + return False + if not current_generation: + return not package_generation + if plugin_info.get(current_generation) is False: + return False + if package_generation == current_generation: + return True + if package_generation in PLUGIN_GENERATION_COMPATIBILITY.get( + current_generation, () + ): + return plugin_info.get(current_generation) is not False + if package_generation: + return False + if plugin_info.get(current_generation) is True: + return True + return any( + plugin_info.get(generation) is True + for generation in PLUGIN_GENERATION_COMPATIBILITY.get( + current_generation, () + ) + ) + + +def check_plugin_system_version( + plugin_info: object, + *, + current_version: str, +) -> tuple[bool, str]: + """检查插件声明的 MoviePilot PEP 440 版本范围。""" + if not isinstance(plugin_info, Mapping): + 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" + ) + try: + system_version = Version(current_version) + except InvalidVersion: + return False, ( + f"当前 MoviePilot 版本 {current_version} 无法解析," + f"已拒绝安装带版本限制的插件" + ) + 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},当前版本 {current_version} 不满足," + f"已拒绝安装" + ) + + +def build_plugin_release_install_plan( + *, + plugin_id: str, + metadata: Mapping[str, Any], + release_version: str | None, + release_items: Sequence[Mapping[str, Any]], + current_version: str, +) -> tuple[PluginReleaseInstallPlan | None, str]: + """统一选择指定 Release、可回退当前 Release 或文件列表安装模式。""" + is_release = metadata.get("release") + plugin_version = metadata.get("version") + if release_version: + if not is_release: + return None, f"{plugin_id} 未声明 Release 安装,无法安装指定版本" + if not any( + item.get("version") == release_version for item in release_items + ): + return None, f"{plugin_id} 未找到可安装的 Release 版本:{release_version}" + if release_version == plugin_version: + compatible, message = check_plugin_system_version( + metadata, + current_version=current_version, + ) + if not compatible: + return None, message + return PluginReleaseInstallPlan( + release_tag=f"{plugin_id}_v{release_version}", + fallback_to_filelist=False, + ), "" + compatible, message = check_plugin_system_version( + metadata, + current_version=current_version, + ) + if not compatible: + return None, message + if not is_release: + return PluginReleaseInstallPlan(None, False), "" + if not plugin_version: + return None, ( + f"未在插件清单中找到 {plugin_id} 的版本号,无法进行 Release 安装" + ) + return PluginReleaseInstallPlan( + release_tag=f"{plugin_id}_v{plugin_version}", + fallback_to_filelist=True, + ), "" diff --git a/app/runtime/extensions/plugin/dependency.py b/app/runtime/extensions/plugin/dependency.py index 5a026f4b2..31d3351bb 100644 --- a/app/runtime/extensions/plugin/dependency.py +++ b/app/runtime/extensions/plugin/dependency.py @@ -3,9 +3,11 @@ import time from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, Optional +from app.runtime.extensions.plugin.registry import PluginRegistry from app.runtime.extensions.plugin.system import PluginSystemServices +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus @dataclass(frozen=True) @@ -32,10 +34,14 @@ class PluginDependencyService: self, *, system: Callable[[], PluginSystemServices], + instances: Optional[Callable[[], dict[str, PluginInstance]]] = None, + registry: Optional[PluginRegistry] = None, log: Any, ) -> None: - """保存插件系统适配器和日志端口。""" + """保存插件系统、虚拟实例和运行状态端口。""" self._system = system + self._instances = instances or (lambda: {}) + self._registry = registry self._logger = log def install_missing_with_status(self) -> PluginDependencyInstallResult: @@ -85,12 +91,54 @@ class PluginDependencyService: return PluginDependencyInstallResult(missing=missing, success=success) def classify_plugins(self) -> PluginDependencyClassification: - """返回启动编排使用的轻量插件分类。""" + """分类物理插件,并把源码结论映射到全部虚拟实例。""" ready, missing_dependencies, missing_source = ( self._system().dependency.classify_plugins() ) + ready = list(ready) + missing_dependencies = list(missing_dependencies) + missing_source = list(missing_source) + source_ready = set(ready) + source_pending = set(missing_dependencies) + for instance in self._instances().values(): + if instance.source_plugin_id in source_ready: + ready.append(instance.instance_id) + elif instance.source_plugin_id in source_pending: + missing_dependencies.append(instance.instance_id) + else: + missing_source.append(instance.instance_id) return PluginDependencyClassification( ready=tuple(ready), missing_dependencies=tuple(missing_dependencies), missing_source=tuple(missing_source), ) + + def apply_classification( + self, + classification: PluginDependencyClassification, + ) -> None: + """把依赖分类写入唯一注册表,已激活插件保持当前状态。""" + if self._registry is None: + raise RuntimeError("插件依赖状态注册表尚未装配") + running_ids = set(self._registry.running_ids()) + for plugin_id in classification.missing_source: + self._registry.set_runtime_status( + plugin_id, + PluginRuntimeStatus.SOURCE_MISSING, + ) + for plugin_id in classification.missing_dependencies: + self._registry.set_runtime_status( + plugin_id, + PluginRuntimeStatus.DEPENDENCY_PENDING, + ) + for plugin_id in classification.ready: + current_status = self._registry.runtime_status(plugin_id) + if ( + plugin_id in running_ids + and current_status is not PluginRuntimeStatus.DEPENDENCY_PENDING + ): + continue + self._registry.set_runtime_status( + plugin_id, + PluginRuntimeStatus.READY, + ) diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py new file mode 100644 index 000000000..6c5967945 --- /dev/null +++ b/app/runtime/extensions/plugin/runtime.py @@ -0,0 +1,324 @@ +"""插件宿主运行时依赖聚合与构造。""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional, Protocol + +from app.foundation.crypto import RSAUtils +from app.foundation.version import compare_version +from app.runtime.events import eventmanager +from app.runtime.extensions.plugin.access import PluginAccessPolicy +from app.runtime.extensions.plugin.admission import PluginMutationAdmission +from app.runtime.extensions.plugin.catalog import PluginCatalogFacade +from app.runtime.extensions.plugin.clone import PluginCloneService +from app.runtime.extensions.plugin.dependency import PluginDependencyService +from app.runtime.extensions.plugin.lifecycle import PluginLifecycle +from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.metadata import PluginMetadataMapper +from app.runtime.extensions.plugin.monitor import PluginMonitorController +from app.runtime.extensions.plugin.paths import PluginPathResolver +from app.runtime.extensions.plugin.projection import PluginProjection +from app.runtime.extensions.plugin.registry import PluginRegistry +from app.runtime.extensions.plugin.storage import ( + PluginConfigStore, + PluginInstanceStore, + PluginStorage, +) +from app.runtime.extensions.plugin.sync import ( + LocalPluginSyncService, + PluginSyncService, +) +from app.runtime.extensions.plugin.system import PluginSystemServices +from app.runtime.extensions.plugin.tools import PluginToolCatalog +from app.schemas.types import SystemConfigKey + + +class PluginRuntimeHost(Protocol): + """声明运行时 owner 回调宿主生命周期门面的最小合同。""" + + def reload_plugin(self, plugin_id: str) -> Any: + """重载指定插件。""" + ... + + def remove_plugin(self, plugin_id: str) -> Any: + """移除指定插件运行实例。""" + ... + + @staticmethod + def get_plugin_remote_entry(plugin_id: str, page: str) -> str: + """构造插件远程页面入口。""" + ... + + def _run_file_watcher(self) -> None: + """运行插件文件监控循环。""" + ... + + def get_plugins_from_market( + self, + market: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> Optional[list[Any]]: + """读取指定市场目录。""" + ... + + async def async_get_plugins_from_market( + self, + market: str, + package_version: Optional[str] = None, + force: bool = False, + ) -> Optional[list[Any]]: + """异步读取指定市场目录。""" + ... + + +PluginCatalogFactory = Callable[[Callable[..., Any]], Any] +PluginImportService = Callable[..., None] +PluginRemoteEntryBuilder = Callable[[str, str], str] + + +@dataclass(frozen=True, slots=True) +class PluginRuntimeEnvironment: + """保存由组合根提供的插件运行时外部端口。""" + + plugins_root: Path + storage: Callable[[], PluginStorage] + system: Callable[[], PluginSystemServices] + catalog_factory: PluginCatalogFactory + import_preparer: PluginImportService + import_scanner: PluginImportService + auth_level: Callable[[], int] + remote_entry: PluginRemoteEntryBuilder + development: Callable[[], bool] + logger: Any + + +@dataclass(frozen=True, slots=True) +class PluginRuntime: + """聚合一个 PluginManager 生命周期内唯一的职责 owner。""" + + registry: PluginRegistry + instances: PluginInstanceStore + configs: PluginConfigStore + access: PluginAccessPolicy + catalog: PluginCatalogFacade + paths: PluginPathResolver + local_sync: LocalPluginSyncService + monitor: PluginMonitorController + admission: PluginMutationAdmission + dependencies: PluginDependencyService + loader: PluginLoader + tools: PluginToolCatalog + lifecycle: PluginLifecycle + metadata: PluginMetadataMapper + sync: PluginSyncService + clone: PluginCloneService + projection: PluginProjection + recent_local_sync: dict[str, float] + system: Callable[[], PluginSystemServices] + + +def build_plugin_runtime( + host: PluginRuntimeHost, + environment: PluginRuntimeEnvironment, + *, + tool_build_max_attempts: int, +) -> PluginRuntime: + """按依赖顺序构造唯一插件运行时,各业务能力仍由对应 owner 实现。""" + registry = PluginRegistry() + instances = PluginInstanceStore(storage=environment.storage) + configs = PluginConfigStore( + storage=environment.storage, + plugin_exists=lambda plugin_id: bool(registry.classes.get(plugin_id)), + ) + access = PluginAccessPolicy( + auth_level=environment.auth_level, + verify_keys=RSAUtils.verify_rsa_keys, + log=environment.logger, + ) + loader = PluginLoader( + plugins_root=environment.plugins_root, + import_preparer=environment.import_preparer, + import_scanner=environment.import_scanner, + log=environment.logger, + ) + tools = PluginToolCatalog(max_attempts=tool_build_max_attempts) + + def load_plugins( + plugin_id: Optional[str], + installed_plugins: list[str], + validator: Callable[[Any], bool], + ) -> list[Any]: + """加载物理插件或虚拟实例,并保持持久化实例顺序。""" + if plugin_id: + instance = instances.get(plugin_id) + if instance: + return loader.load_instance(instance, validator) + return loader.load(plugin_id, installed_plugins, validator) + plugins = loader.load(None, installed_plugins, validator) + for instance in instances.all().values(): + plugins.extend(loader.load_instance(instance, validator)) + return plugins + + lifecycle = PluginLifecycle( + classes=registry.classes, + running=registry.running, + load_plugins=load_plugins, + installed_plugins=lambda: environment.storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [], + plugin_config=configs.read, + auth_checker=lambda plugin: access.check(plugin), + clear_modules=loader.clear_modules, + clear_tools=tools.clear, + enable_events=eventmanager.enable_event_handler, + disable_events=eventmanager.disable_event_handler, + runtime_status_writer=registry.set_runtime_status, + log=environment.logger, + event_sender=eventmanager.send_event, + ) + metadata = PluginMetadataMapper( + plugin_instance=registry.instance, + plugin_class=registry.plugin_class, + annotate_system_version=lambda info: environment.system().annotate_system_version( + info + ), + is_package_compatible=lambda info, version: environment.system().is_package_compatible( + info, + version, + ), + auth_checker=lambda plugin, source: access.check(plugin, source), + version_compare=lambda source, comparison, target: ( + compare_version(source, comparison, target) is True + ), + log=environment.logger, + ) + catalog = PluginCatalogFacade( + classes=lambda: registry.classes, + running=lambda: registry.running, + storage=environment.storage, + system=environment.system, + market_catalog=lambda: environment.catalog_factory(metadata.map), + market_loader=lambda market, package_version=None, force=False: ( + host.get_plugins_from_market(market, package_version, force) + ), + async_market_loader=lambda market, package_version=None, force=False: ( + host.async_get_plugins_from_market( + market, + package_version, + force, + ) + ), + map_plugin=lambda **kwargs: metadata.map( + plugin_id=kwargs["pid"], + plugin_info=kwargs["plugin_info"], + market=kwargs["market"], + installed_plugins=kwargs["installed_apps"], + add_time=kwargs["add_time"], + package_version=kwargs.get("package_version"), + ), + auth_checker=lambda **kwargs: access.check(**kwargs), + plugin_attr=lambda plugin_id, attribute: getattr( + registry.instance(plugin_id), + attribute, + None, + ), + plugin_instance=instances.get, + plugin_instances=instances.all, + runtime_status=registry.runtime_status, + log=environment.logger, + ) + paths = PluginPathResolver( + runtime_root=environment.plugins_root, + running=lambda: registry.running, + system=environment.system, + strict_system_version=lambda: not environment.development(), + log=environment.logger, + ) + recent_local_sync: dict[str, float] = {} + local_sync = LocalPluginSyncService( + installed_plugins=lambda: environment.storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [], + candidate=lambda plugin_id: environment.system().local_candidate(plugin_id), + system=environment.system, + recent_sync=recent_local_sync, + log=environment.logger, + ) + dependencies = PluginDependencyService( + system=environment.system, + instances=instances.all, + registry=registry, + log=environment.logger, + ) + sync = PluginSyncService( + frozen=lambda: environment.system().is_frozen(), + installed_plugins=lambda: environment.storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [], + online_plugins=catalog.online, + local_plugins=catalog.local_repository, + merge_plugins=lambda higher, base, _markets: catalog.merge(higher, base), + plugin_exists=catalog.exists, + install=lambda plugin_id, repo_url, force, startup_token: environment.system().install_plugin( + plugin_id=plugin_id, + repo_url=repo_url, + force=force, + startup_token=startup_token, + ), + log=environment.logger, + ) + def source_plugin_id(plugin_id: str) -> str: + """把虚拟实例归一到持久化的物理源码插件。""" + instance = instances.get(plugin_id) + return instance.source_plugin_id if instance else plugin_id + + clone = PluginCloneService( + plugin_class=registry.plugin_class, + plugin_exists=catalog.exists, + source_plugin_id=source_plugin_id, + save_instance=instances.save, + delete_instance=instances.delete, + read_config=configs.read, + save_config=lambda plugin_id, config: configs.write( + plugin_id, + config, + force=True, + ), + delete_config=lambda plugin_id: configs.delete(plugin_id, force=True), + reload_plugin=host.reload_plugin, + remove_plugin=host.remove_plugin, + log=environment.logger, + ) + projection = PluginProjection( + registry.running, + environment.logger, + environment.remote_entry, + ) + return PluginRuntime( + registry=registry, + instances=instances, + configs=configs, + access=access, + catalog=catalog, + paths=paths, + local_sync=local_sync, + monitor=PluginMonitorController( + runner=host._run_file_watcher, + log=environment.logger, + ), + admission=PluginMutationAdmission(), + dependencies=dependencies, + loader=loader, + tools=tools, + lifecycle=lifecycle, + metadata=metadata, + sync=sync, + clone=clone, + projection=projection, + recent_local_sync=recent_local_sync, + system=environment.system, + ) diff --git a/app/runtime/extensions/plugin/system.py b/app/runtime/extensions/plugin/system.py index 0dc06ea20..9e6eab44a 100644 --- a/app/runtime/extensions/plugin/system.py +++ b/app/runtime/extensions/plugin/system.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, cast class PluginSystemServices: @@ -91,6 +91,38 @@ class PluginSystemServices: startup_token=startup_token, ) + def remove_plugin_package(self, plugin_id: str) -> bool: + """通过唯一包事务 owner 删除插件物理目录。""" + return bool(self.package.remove_plugin(plugin_id)) + + def modify_plugin_files(self, **kwargs: Any) -> tuple[bool, str]: + """把旧分身源码改写调用收口到唯一包适配器。""" + return cast(tuple[bool, str], self.package._modify_plugin_files(**kwargs)) + + def modify_python_file(self, **kwargs: Any) -> tuple[bool, str]: + """把旧 Python 类改写调用收口到唯一包适配器。""" + return cast(tuple[bool, str], self.package._modify_python_file(**kwargs)) + + def modify_federation_files(self, **kwargs: Any) -> tuple[bool, str]: + """把旧联邦构建产物改写调用收口到唯一包适配器。""" + return cast( + tuple[bool, str], + self.package._modify_federation_files(**kwargs), + ) + + def rename_federation_assets( + self, + dist_dir: Path, + original_class_name: str, + clone_class_name: str, + ) -> None: + """把旧联邦资源重命名调用收口到唯一包适配器。""" + self.package._rename_federation_assets( + dist_dir, + original_class_name, + clone_class_name, + ) + _services: Optional[PluginSystemServices] = None diff --git a/app/runtime/extensions/plugin_manager.py b/app/runtime/extensions/plugin_manager.py index fd7301faf..612e41150 100644 --- a/app/runtime/extensions/plugin_manager.py +++ b/app/runtime/extensions/plugin_manager.py @@ -20,37 +20,19 @@ from typing import ( from watchfiles import watch -from app.foundation.crypto import RSAUtils from app.foundation.environment import is_free_threaded_runtime, is_gil_enabled from app.foundation.singleton import Singleton -from app.foundation.version import compare_version from app.runtime.events import EventHandlerBinding, eventmanager from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.extensions.plugin.access import PluginAccessPolicy -from app.runtime.extensions.plugin.admission import PluginMutationAdmission -from app.runtime.extensions.plugin.catalog import PluginCatalogFacade -from app.runtime.extensions.plugin.clone import PluginCloneService from app.runtime.extensions.plugin.dependency import ( PluginDependencyClassification, PluginDependencyInstallResult, - PluginDependencyService, ) -from app.runtime.extensions.plugin.lifecycle import PluginLifecycle -from app.runtime.extensions.plugin.loader import PluginLoader from app.runtime.extensions.plugin.metadata import PluginMetadataMapper -from app.runtime.extensions.plugin.monitor import ( - PluginChangeMonitor, - PluginMonitorController, -) -from app.runtime.extensions.plugin.paths import PluginPathResolver +from app.runtime.extensions.plugin.monitor import PluginChangeMonitor from app.runtime.extensions.plugin.projection import PluginProjection -from app.runtime.extensions.plugin.registry import PluginRegistry -from app.runtime.extensions.plugin.storage import PluginConfigStore, PluginInstanceStore, get_plugin_storage -from app.runtime.extensions.plugin.sync import ( - LocalPluginSyncService, - PluginSyncService, -) -from app.runtime.extensions.plugin.system import get_plugin_system +from app.runtime.extensions.plugin.runtime import PluginRuntime from app.runtime.extensions.plugin.tools import PluginToolCatalog from app.runtime.log import logger from app.runtime.observability import observe_compat_facade @@ -61,13 +43,14 @@ from app.schemas.exception import PluginMutationRejectedError from app.schemas.plugin import Plugin as _SchemaPlugin from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard from app.schemas.plugin import PluginInstance, PluginRuntimeStatus -from app.schemas.types import EventType, SystemConfigKey +from app.schemas.types import EventType LegacyDiagnosticsConfigurator = Callable[..., None] LegacyImportScanner = Callable[..., None] LegacyPluginImportPreparer = Callable[..., None] SiteAuthLevelProvider = Callable[[], int] -PluginCatalogFactory = Callable[["PluginManager"], Any] +PluginCatalogFactory = Callable[[Callable[..., Any]], Any] +PluginRuntimeFactory = Callable[["PluginManager"], PluginRuntime] PluginRouteRefresher = Callable[[str], None] @@ -84,11 +67,16 @@ def _unavailable_site_auth_level() -> int: return 0 -def _unavailable_plugin_catalog_factory(_manager: "PluginManager") -> Any: +def _unavailable_plugin_catalog_factory(_mapper: Callable[..., Any]) -> Any: """在启动组合根尚未装配目录用例时拒绝隐式跨层构造。""" raise RuntimeError("插件目录应用服务尚未由启动组合根装配") +def _unavailable_plugin_runtime_factory(_manager: "PluginManager") -> PluginRuntime: + """拒绝在启动组合根尚未注入依赖图时隐式构造插件 Runtime。""" + raise RuntimeError("插件 Runtime 工厂尚未由启动组合根装配") + + def _warn_if_plugin_enabled_gil( *, gil_enabled_before: bool, @@ -121,6 +109,7 @@ _legacy_plugin_import_preparer: LegacyPluginImportPreparer = ( ) _site_auth_level_provider: SiteAuthLevelProvider = _unavailable_site_auth_level _plugin_catalog_factory: PluginCatalogFactory = _unavailable_plugin_catalog_factory +_plugin_runtime_factory: PluginRuntimeFactory = _unavailable_plugin_runtime_factory _plugin_route_refresher: PluginRouteRefresher = _unavailable_plugin_route_refresher @@ -155,12 +144,31 @@ def configure_plugin_catalog_factory(factory: PluginCatalogFactory) -> None: _plugin_catalog_factory = factory +def configure_plugin_runtime_factory(factory: PluginRuntimeFactory) -> None: + """由启动组合根注入完整插件 Runtime 工厂。""" + global _plugin_runtime_factory + _plugin_runtime_factory = factory + + +def reset_plugin_runtime_factory() -> None: + """恢复未装配工厂,仅供隔离测试清理进程级依赖。""" + global _plugin_runtime_factory + _plugin_runtime_factory = _unavailable_plugin_runtime_factory + + def configure_plugin_route_refresher(refresher: PluginRouteRefresher) -> None: """由启动组合根注入热重载后的动态路由投影刷新器。""" global _plugin_route_refresher _plugin_route_refresher = refresher +def _resolve_plugin_handler_instance( + owner_class: Type[Any], +) -> Optional[EventHandlerBinding]: + """通过当前插件管理器单例解析实例,避免事件总线持有过期对象。""" + return PluginManager().resolve_event_handler_instance(owner_class) + + @observe_compat_facade("PluginManager") class PluginManager(ConfigReloadMixin, metaclass=Singleton): """插件管理器""" @@ -169,72 +177,11 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # 略长于 watchfiles 默认 debounce,吸收包写入完成后才交付的延迟批次。 MONITOR_SETTLE_SECONDS = 2.0 - def __init__(self): - """初始化插件注册表、缓存和开发模式监控状态。""" - self._plugin_registry = PluginRegistry() - # 旧属性继续引用注册表拥有的可变字典,保持插件和测试的访问身份。 - self._plugins = self._plugin_registry.classes - self._running_plugins = self._plugin_registry.running - self._plugin_instance_store = PluginInstanceStore( - storage=lambda: get_plugin_storage(), - ) - # 配置Key - self._config_key: str = "plugin.%s" - self._plugin_config_store = PluginConfigStore( - storage=lambda: get_plugin_storage(), - plugin_exists=lambda pid: bool(self._plugins.get(pid)), - key_prefix=self._config_key, - ) - self._plugin_access = PluginAccessPolicy( - auth_level=lambda: _site_auth_level_provider(), - verify_keys=RSAUtils.verify_rsa_keys, - log=logger, - ) - self._plugin_catalog_view = PluginCatalogFacade( - classes=lambda: self._plugins, - running=lambda: self._running_plugins, - storage=lambda: get_plugin_storage(), - system=get_plugin_system, - market_catalog=lambda: self._plugin_catalog(), - market_loader=lambda market, package_version=None, force=False: ( - self.get_plugins_from_market(market, package_version, force) - ), - async_market_loader=lambda market, package_version=None, force=False: ( - self.async_get_plugins_from_market(market, package_version, force) - ), - map_plugin=lambda **kwargs: self._process_plugin_info(**kwargs), - auth_checker=lambda **kwargs: self.__set_and_check_auth_level(**kwargs), - plugin_attr=lambda pid, attr: self.get_plugin_attr(pid, attr), - plugin_instance=self.get_plugin_instance, - plugin_instances=self.get_plugin_instances, - runtime_status=self._plugin_registry.runtime_status, - log=logger, - ) - # 本地插件同步写入运行目录后的短时忽略窗口 - self._recent_local_sync: Dict[str, float] = {} + def __init__(self) -> None: + """消费组合根注入的唯一 Runtime,并保留旧私有字段的对象身份。""" self._monitor_suppression_lock = threading.Lock() self._suppressed_monitor_plugins: Dict[str, int] = {} self._monitor_suppressed_until: Dict[str, float] = {} - self._plugin_paths = PluginPathResolver( - runtime_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", - running=lambda: self._running_plugins, - system=get_plugin_system, - strict_system_version=lambda: not get_runtime_setting('DEV'), - log=logger, - ) - self._local_plugin_sync = LocalPluginSyncService( - installed_plugins=lambda: get_plugin_storage().read( - SystemConfigKey.UserInstalledPlugins - ) or [], - candidate=lambda pid: get_plugin_system().local_candidate(pid), - system=get_plugin_system, - recent_sync=self._recent_local_sync, - log=logger, - ) - self._plugin_monitor = PluginMonitorController( - runner=self._run_file_watcher, - log=logger, - ) self._plugin_quiesce_lock = threading.RLock() self._plugin_quiesce_future: Optional[ concurrent.futures.Future[bool] @@ -242,106 +189,31 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._plugin_service_quiesce_future: Optional[ concurrent.futures.Future[bool] ] = None - self._plugin_mutation_admission = PluginMutationAdmission() self._plugin_runtime_closed = False - self._plugin_dependencies = PluginDependencyService( - system=get_plugin_system, - log=logger, - ) - self._plugin_loader = PluginLoader( - plugins_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", - import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), - import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), - log=logger, - ) - self._plugin_tool_catalog = PluginToolCatalog( - max_attempts=self.AGENT_TOOLS_BUILD_MAX_ATTEMPTS - ) - self._plugin_lifecycle = PluginLifecycle( - classes=self._plugins, - running=self._running_plugins, - load_plugins=lambda pid, installed, check: self._load_runtime_plugins( - pid, - installed, - check, - ), - installed_plugins=lambda: get_plugin_storage().read( - SystemConfigKey.UserInstalledPlugins - ) or [], - plugin_config=self.get_plugin_config, - auth_checker=lambda plugin: self.__set_and_check_auth_level(plugin=plugin), - clear_modules=lambda pid: self._clear_plugin_modules(pid), - clear_tools=self.clear_plugin_agent_tools_cache, - enable_events=eventmanager.enable_event_handler, - disable_events=eventmanager.disable_event_handler, - runtime_status_writer=self._plugin_registry.set_runtime_status, - log=logger, - event_sender=eventmanager.send_event, - ) - self._plugin_metadata = PluginMetadataMapper( - plugin_instance=self._plugin_registry.instance, - plugin_class=self._plugin_registry.plugin_class, - annotate_system_version=lambda info: get_plugin_system().annotate_system_version( - info - ), - is_package_compatible=lambda info, version: get_plugin_system().is_package_compatible( - info, - version, - ), - auth_checker=lambda plugin, source: self.__set_and_check_auth_level( - plugin, - source, - ), - version_compare=compare_version, - log=logger, - ) - self._plugin_sync = PluginSyncService( - frozen=lambda: get_plugin_system().is_frozen(), - installed_plugins=lambda: get_plugin_storage().read( - SystemConfigKey.UserInstalledPlugins - ) or [], - online_plugins=lambda: self.get_online_plugins(), - local_plugins=lambda: self.get_local_repo_plugins(), - merge_plugins=lambda higher, base, _markets: self.process_plugins_list( - higher, - base, - ), - plugin_exists=lambda plugin_id, version: self.is_plugin_exists( - plugin_id, - version, - ), - install=lambda plugin_id, repo_url, force, startup_token: get_plugin_system().install_plugin( - plugin_id=plugin_id, - repo_url=repo_url, - force=force, - startup_token=startup_token, - ), - log=logger, - ) - self._plugin_clone = PluginCloneService( - plugin_class=self._plugin_registry.plugin_class, - plugin_exists=lambda plugin_id: self.is_plugin_exists(plugin_id), - source_plugin_id=self.get_plugin_source_id, - save_instance=self._plugin_instance_store.save, - delete_instance=self._plugin_instance_store.delete, - read_config=self.get_plugin_config, - save_config=lambda plugin_id, config: self.save_plugin_config( - plugin_id, - config, - force=True, - ), - delete_config=lambda plugin_id: self.delete_plugin_config( - plugin_id, - force=True, - ), - reload_plugin=self.reload_plugin, - remove_plugin=self.remove_plugin, - log=logger, - ) + self._plugin_runtime = _plugin_runtime_factory(self) + self._plugin_registry = self._plugin_runtime.registry + self._plugins = self._plugin_registry.classes + self._running_plugins = self._plugin_registry.running + self._plugin_instance_store = self._plugin_runtime.instances + self._plugin_config_store = self._plugin_runtime.configs + self._plugin_access = self._plugin_runtime.access + self._plugin_catalog_view = self._plugin_runtime.catalog + self._recent_local_sync = self._plugin_runtime.recent_local_sync + self._plugin_paths = self._plugin_runtime.paths + self._local_plugin_sync = self._plugin_runtime.local_sync + self._plugin_monitor = self._plugin_runtime.monitor + self._plugin_mutation_admission = self._plugin_runtime.admission + self._plugin_dependencies = self._plugin_runtime.dependencies + self._plugin_loader = self._plugin_runtime.loader + self._plugin_tool_catalog = self._plugin_runtime.tools + self._plugin_lifecycle = self._plugin_runtime.lifecycle + self._plugin_metadata = self._plugin_runtime.metadata + self._plugin_sync = self._plugin_runtime.sync + self._plugin_clone = self._plugin_runtime.clone # 事件总线只通过通用解析器访问运行中的插件实例。 eventmanager.register_handler_instance_resolver( "plugins", - self.resolve_event_handler_instance, + _resolve_plugin_handler_instance, ) def resolve_event_handler_instance( self, @@ -554,47 +426,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._plugin_mutation_admission.wait_until_idle() return self._plugin_lifecycle.quiesce_handlers() - @staticmethod - def _load_selective_plugins(pid: Optional[str], installed_plugins: List[str], - check_module_func: Callable) -> List[Any]: - """ - 选择性加载插件,只import符合条件的插件 - :param pid: 指定插件ID,为空则加载所有已安装插件 - :param installed_plugins: 已安装插件列表 - :param check_module_func: 模块检查函数 - :return: 插件类列表 - """ - return PluginLoader( - plugins_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", - import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), - import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), - log=logger, - ).load(pid, installed_plugins, check_module_func) - - def _load_runtime_plugins( - self, - pid: Optional[str], - installed_plugins: List[str], - check_module_func: Callable[[Any], bool], - ) -> List[Any]: - """加载物理插件或虚拟实例,并保留旧选择性加载方法的合同。""" - if pid: - instance = self._plugin_instance_store.get(pid) - if instance: - return self._plugin_loader.load_instance(instance, check_module_func) - return self._plugin_loader.load(pid, installed_plugins, check_module_func) - - plugins = self._plugin_loader.load( - None, - installed_plugins, - check_module_func, - ) - for instance in self._plugin_instance_store.all().values(): - plugins.extend( - self._plugin_loader.load_instance(instance, check_module_func) - ) - return plugins - @property def running_plugins(self) -> Dict[str, Any]: """ @@ -660,7 +491,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ PluginChangeMonitor( runtime_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", - local_roots=get_plugin_system().local_repo_paths, + local_roots=self._plugin_runtime.system().local_repo_paths, stop_event=self._plugin_monitor.stop_event, recent_sync=self._recent_local_sync, federated_change=self._get_federated_plugin_change, @@ -670,7 +501,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): sync_local=self._sync_local_plugin_if_installed, reload_plugin=self._reload_plugin_tree_from_monitor, dependency_manifest_status=( - get_plugin_system().dependency_manifest_status + self._plugin_runtime.system().dependency_manifest_status ), watch=watch, log=logger, @@ -773,6 +604,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): except PluginMutationRejectedError as error: logger.warning(str(error)) + def remove_plugin_package(self, plugin_id: str) -> bool: + """把插件物理目录删除委托给唯一包文件事务 owner。""" + return self._plugin_runtime.system().remove_plugin_package(plugin_id) + def reload_plugin(self, plugin_id: str) -> PluginRuntimeStatus: """ 将一个插件重新加载到内存 @@ -823,20 +658,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): ), ] - @staticmethod - def _clear_plugin_modules(plugin_id: Optional[str] = None): - """ - 清除插件及其所有子模块的缓存 - :param plugin_id: 插件ID - """ - - return PluginLoader( - plugins_root=get_runtime_setting('ROOT_PATH') / "app" / "plugins", - import_preparer=lambda **kwargs: _legacy_plugin_import_preparer(**kwargs), - import_scanner=lambda **kwargs: _legacy_import_scanner(**kwargs), - log=logger, - ).clear_modules(plugin_id) - def sync( self, startup_token: object | None = None, @@ -860,82 +681,32 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ manager = PluginManager() with manager.mutation("安装插件依赖"): - return PluginDependencyService( - system=get_plugin_system, - log=logger, - ).install_missing() + return manager._plugin_dependencies.install_missing() @staticmethod def install_plugin_missing_dependencies_with_status() -> PluginDependencyInstallResult: """安装插件缺失依赖并返回缺失项及安装成功状态。""" manager = PluginManager() with manager.mutation("安装插件依赖"): - return PluginDependencyService( - system=get_plugin_system, - log=logger, - ).install_missing_with_status() + return manager._plugin_dependencies.install_missing_with_status() @staticmethod async def async_install_plugin_missing_dependencies_with_status() -> PluginDependencyInstallResult: """在异步启动链中恢复插件依赖并保留取消语义。""" manager = PluginManager() with manager.mutation("安装插件依赖"): - return await PluginDependencyService( - system=get_plugin_system, - log=logger, - ).async_install_missing_with_status() + return await manager._plugin_dependencies.async_install_missing_with_status() def classify_plugins(self) -> PluginDependencyClassification: - """按源码依赖状态分类物理插件,并把结果映射到虚拟实例。""" - source_classification = PluginDependencyService( - system=get_plugin_system, - log=logger, - ).classify_plugins() - ready = list(source_classification.ready) - missing_dependencies = list(source_classification.missing_dependencies) - missing_source = list(source_classification.missing_source) - source_ready = set(source_classification.ready) - source_pending = set(source_classification.missing_dependencies) - for instance in self._plugin_instance_store.all().values(): - if instance.source_plugin_id in source_ready: - ready.append(instance.instance_id) - elif instance.source_plugin_id in source_pending: - missing_dependencies.append(instance.instance_id) - else: - missing_source.append(instance.instance_id) - return PluginDependencyClassification( - ready=tuple(ready), - missing_dependencies=tuple(missing_dependencies), - missing_source=tuple(missing_source), - ) + """返回依赖 owner 计算的物理插件与虚拟实例分类。""" + return self._plugin_dependencies.classify_plugins() def apply_plugin_dependency_classification( self, classification: PluginDependencyClassification, ) -> None: - """把源码和依赖分类写入运行状态,已激活插件保持当前结果。""" - running_ids = set(self._plugin_registry.running_ids()) - for plugin_id in classification.missing_source: - self._plugin_registry.set_runtime_status( - plugin_id, - PluginRuntimeStatus.SOURCE_MISSING, - ) - for plugin_id in classification.missing_dependencies: - self._plugin_registry.set_runtime_status( - plugin_id, - PluginRuntimeStatus.DEPENDENCY_PENDING, - ) - for plugin_id in classification.ready: - current_status = self._plugin_registry.runtime_status(plugin_id) - if ( - plugin_id in running_ids - and current_status is not PluginRuntimeStatus.DEPENDENCY_PENDING - ): - continue - self._plugin_registry.set_runtime_status( - plugin_id, - PluginRuntimeStatus.READY, - ) + """把分类结果委托依赖 owner 写入唯一运行状态注册表。""" + self._plugin_dependencies.apply_classification(classification) def set_plugin_settling(self, settling: bool) -> None: """更新启动后的插件恢复任务状态。""" @@ -1063,16 +834,12 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return plugin.get_state() if plugin else False def _plugin_projection(self) -> PluginProjection: - """构造绑定当前运行态插件注册表的能力投影服务。""" - return PluginProjection( - self._plugin_registry.running, - logger, - self.get_plugin_remote_entry, - ) + """返回类型化 Runtime 持有的唯一能力投影。""" + return self._plugin_runtime.projection def _plugin_catalog(self) -> Any: - """构造绑定当前市场客户端和插件 DTO 映射器的目录应用服务。""" - return _plugin_catalog_factory(self) + """兼容旧私有入口,返回绑定唯一元数据 owner 的目录服务。""" + return _plugin_catalog_factory(self._plugin_metadata.map) def get_plugin_commands(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: """ @@ -1478,7 +1245,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): original_plugin_class = self._plugins.get(original_id) if not original_plugin_class: return False, f"无法获取原插件类 {original_id}" - return get_plugin_system().package._modify_plugin_files( + return self._plugin_runtime.system().modify_plugin_files( plugin_dir=plugin_dir, original_class_name=original_plugin_class.__name__, suffix=suffix, @@ -1495,7 +1262,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 兼容旧内部调用,将 Python 文件改写委托给包适配器。 """ - return get_plugin_system().package._modify_python_file( + return PluginManager()._plugin_runtime.system().modify_python_file( file_path=file_path, original_class_name=original_class_name, clone_class_name=clone_class_name, @@ -1510,7 +1277,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 兼容旧内部调用,将联邦文件改写委托给包适配器。 """ - return get_plugin_system().package._modify_federation_files( + return self._plugin_runtime.system().modify_federation_files( dist_dir=dist_dir, original_class_name=original_class_name, clone_class_name=clone_class_name, @@ -1521,7 +1288,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ 兼容旧内部调用,将资源重命名委托给包适配器。 """ - get_plugin_system().package._rename_federation_assets( + PluginManager()._plugin_runtime.system().rename_federation_assets( dist_dir, original_class_name, clone_class_name, diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index adf042c25..c782e043c 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -1,17 +1,22 @@ import asyncio import uuid +from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path +from typing import Any from app.adapters.external.market import ( LOCAL_REPO_PREFIX, - VERSION_BACKWARD_COMPATIBLE_FLAGS, - PluginHelper, configure_installed_plugins_provider, configure_plugin_install_gateway, +) +from app.adapters.external.plugin.client import ( + VERSION_BACKWARD_COMPATIBLE_FLAGS, + PluginMarketClient, + PluginMarketTransport, + PluginPackageSourceClient, split_plugin_market_repo_urls, ) -from app.adapters.external.plugin.client import PluginMarketClient from app.adapters.external.server import MoviePilotServerHelper from app.adapters.system.host import SystemUtils from app.adapters.system.plugin.dependency import PluginDependencyInstaller @@ -80,13 +85,21 @@ from app.runtime.compat.diagnostics import ( from app.runtime.compat.resource_imports import scan_plugin_resource_imports from app.runtime.execution import run_in_threadpool_to_completion from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult +from app.runtime.extensions.plugin.runtime import ( + PluginRuntime, + PluginRuntimeEnvironment, + PluginRuntimeHost, + build_plugin_runtime, +) from app.runtime.extensions.plugin.storage import ( PluginStorage, configure_plugin_storage, + get_plugin_storage, ) from app.runtime.extensions.plugin.system import ( PluginSystemServices, configure_plugin_system, + get_plugin_system, ) from app.runtime.extensions.plugin_manager import ( PluginManager, @@ -94,6 +107,7 @@ from app.runtime.extensions.plugin_manager import ( configure_plugin_legacy_import_services, configure_plugin_resource_import_preparer, configure_plugin_route_refresher, + configure_plugin_runtime_factory, configure_site_auth_level_provider, ) from app.runtime.log import logger @@ -131,11 +145,36 @@ def _prepare_legacy_plugin_import(*, plugin_id: str, plugin_dir: Path) -> None: ) +def build_plugin_runtime_graph(host: PluginRuntimeHost) -> PluginRuntime: + """在启动组合根构造插件 Runtime 的完整依赖图。""" + return build_plugin_runtime( + host, + PluginRuntimeEnvironment( + plugins_root=Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins", + storage=lambda: get_plugin_storage(), + system=lambda: get_plugin_system(), + catalog_factory=lambda mapper: _build_plugin_catalog(mapper), + import_preparer=_prepare_legacy_plugin_import, + import_scanner=scan_plugin_legacy_imports, + auth_level=lambda: SitesHelper().auth_level, + remote_entry=host.get_plugin_remote_entry, + development=lambda: bool(get_runtime_setting('DEV')), + logger=logger, + ), + tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS, + ) + + def configure_plugin_services() -> None: """把兼容诊断、远程上报和站点认证等级装配到插件管理器。""" - plugin_helper = PluginHelper() - market_client = PluginMarketClient(plugin_helper) - package_manager = PluginPackageManager(plugin_helper) + market_transport = PluginMarketTransport() + market_client = PluginMarketClient(market_transport) + package_manager = PluginPackageManager( + source=PluginPackageSourceClient(market_transport), + plugin_root=Path(get_runtime_setting('ROOT_PATH')) / "app" / "plugins", + ) + configure_plugin_catalog_factory(_build_plugin_catalog) + configure_plugin_runtime_factory(build_plugin_runtime_graph) plugin_manager = get_plugin_manager() inventory_reader = PluginCandidateInventoryReader( market_loader=market_client.get_plugin_index_result, @@ -147,7 +186,9 @@ def configure_plugin_services() -> None: async def refresh_plugin_releases(plugin_id: str, repo_url: str) -> None: """绕过已有 Release 缓存执行后台刷新。""" async with async_fresh(True): - await plugin_helper.async_get_plugin_release_versions(plugin_id, repo_url) + await market_transport.async_get_plugin_release_versions( + plugin_id, repo_url + ) configure_plugin_release_service( PluginReleaseService( @@ -160,8 +201,8 @@ def configure_plugin_services() -> None: compatible_flags=lambda flag: ( VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) if flag else [] ), - has_release_cache=plugin_helper.async_has_plugin_release_cache, - releases=plugin_helper.async_get_plugin_release_versions, + has_release_cache=market_transport.async_has_plugin_release_cache, + releases=market_transport.async_get_plugin_release_versions, refresh_releases=refresh_plugin_releases, ) ) @@ -236,7 +277,7 @@ def configure_plugin_services() -> None: inventory=load_inventory, identity=persistence.get_identity, candidate_compatibility=lambda candidate: ( - plugin_helper.check_plugin_system_version(candidate.dto) + market_transport.check_plugin_system_version(candidate.dto) ), executor=command, clock=lambda: datetime.now(timezone.utc), @@ -315,13 +356,11 @@ def configure_plugin_services() -> None: configure_installed_plugins_provider( lambda: get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or [] ) - configure_plugin_catalog_factory(_build_plugin_catalog) configure_plugin_route_refresher(register_plugin_api) configure_plugin_system(PluginSystemServices( market=market_client, package=package_manager, dependency=PluginDependencyInstaller( - plugin_helper, installed_plugins_provider=lambda: get_configured_system_config().get( SystemConfigKey.UserInstalledPlugins ) or [], @@ -450,7 +489,7 @@ def _run_plugin_install_sync( return False, str(error) -def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService: +def _build_plugin_catalog(plugin_mapper: Callable[..., Any]) -> PluginCatalogService: """在组合根连接目录用例、市场客户端、持久化读取和插件 DTO 映射。""" client = PluginMarketClient() return PluginCatalogService( @@ -459,7 +498,7 @@ def _build_plugin_catalog(manager: PluginManager) -> PluginCatalogService: installed_plugins_provider=lambda: get_configured_system_config().get( SystemConfigKey.UserInstalledPlugins ) or [], - plugin_mapper=manager._process_plugin_info, + plugin_mapper=plugin_mapper, is_local_repo=PluginMarketClient.is_local_repo_url, version_compare=compare_version, warning=logger.warning, @@ -476,9 +515,9 @@ async def sync_plugins( plugin_manager = None try: loop = main_loop_registry.require() + configure_plugin_services() plugin_manager = PluginManager() with plugin_manager.mutation("启动后同步插件"): - configure_plugin_services() await get_plugin_identity_migration().migrate() installed_plugins = get_configured_system_config().get( SystemConfigKey.UserInstalledPlugins diff --git a/app/startup/lifecycle/__init__.py b/app/startup/lifecycle/__init__.py index 644e3959e..7e6ac1a9e 100644 --- a/app/startup/lifecycle/__init__.py +++ b/app/startup/lifecycle/__init__.py @@ -347,7 +347,6 @@ async def stop_task_registry(app: FastAPI) -> bool: async def prepare_plugin_restore() -> None: """先恢复未完成安装事务,再加载持久插件备份及其依赖。""" - configure_plugin_services() await get_plugin_installation_recovery().replay() await run_in_threadpool_to_completion(SystemChain().restore_plugins) @@ -469,10 +468,18 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]: start_order=60, start_timeout_seconds=30, ), + LifecycleComponent( + name="插件服务装配", + dependencies=("路由",), + start=configure_plugin_services, + start_order=65, + start_timeout_seconds=30, + ), LifecycleComponent( name="模块服务", dependencies=( "路由", + "插件服务装配", "站点访问端口", "Chain 外部端口", "Chain 网络端口", diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index 624cd3ef0..51b8fa69a 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -70,7 +70,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 947 / 8,038 | `dependency-baseline.json` 当前快照 | +| 宿主 Python 模块 / 内部依赖边 | 950 / 8,075 | `dependency-baseline.json` 当前快照 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -78,9 +78,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 | | 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 | -| 全量 mypy 历史债务 | 10,686 / 597 文件 | strict frontier 当前覆盖 41 个文件,Media 同名包及受益调用方低水位已固化 | -| Ruff 历史诊断 | 657 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | -| 覆盖率低水位 | Application 80.75%,Domain 79.32% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | +| 全量 mypy 历史债务 | 10,496 / 594 文件 | strict frontier 当前覆盖 41 个文件,Plugin owner 拆分及受益调用方低水位已固化 | +| Ruff 历史诊断 | 654 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | +| 覆盖率低水位 | Application 80.92%,Domain 79.87% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | ### 3.3 热点文件 @@ -89,7 +89,10 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` | `app/chain/subscribe/`(已治理) | Facade 91 行 | 搜索、匹配、刷新、完成、规则引用与通知已拆至单一 owner;原 3,895 行单文件已删除 | | `app/agent/orchestrator.py` | 3,678 | 会话、运行、流式输出、工具、任务与 Manager | | `app/agent/llm/provider.py` | 3,528 | provider 目录、认证、模型发现、配置和运行时构建 | -| `app/adapters/external/market.py` | 3,488 | 插件市场、包下载、依赖、备份恢复、健康和兼容入口 | +| `app/adapters/external/market.py`(已治理) | Facade 226 行 | `PluginHelper` 只保留精确旧 ABI、安装 Gateway 及 owner 委托 | +| `app/adapters/external/plugin/client.py` | 1,600 | 插件市场传输、索引、Release 与本地仓候选查询 | +| `app/adapters/system/plugin/package.py` | 2,324 | 插件安装、checkpoint、备份、恢复和物理删除事务 | +| `app/adapters/system/plugin/health.py` | 1,060 | 插件运行环境保护、安装前后检查与故障恢复 | | `app/chain/transfer/`(已治理) | Facade 82 行 | 队列/恢复、规划、执行、结算、历史/通知已拆至单一 owner;旧 `transfer.py` 与 `_transfer.py` 已删除 | | `app/chain/search.py` | 2,970 | 搜索计划、并发 fan-out、状态、分页和结果处理 | | `app/api/endpoints/agent.py` | 2,346 | HTTP/SSE、文件/音频、Agent 会话和事件编排 | diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 4b86e91a9..59bf4c167 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -728,8 +728,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 947 | -| 内部导入边 | 8,038 | +| Python 模块 | 950 | +| 内部导入边 | 8,075 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 55(债务已清零,55 条精确 containment) | diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 455c92efb..c4b849d39 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -218,8 +218,8 @@ not a second application-facing service directory. | `app/adapters/external/` | CookieCloud, plugin market, OCR, IP-location providers and MoviePilot Server | | `app/adapters/web/` | FastAPI-specific technical adapters, including raw dynamic plugin routes | | `app/adapters/observability/` | Optional telemetry exporters; core code depends only on runtime observation ports | -| `app/adapters/external/plugin/client.py` | Read-only plugin-market and local-repository client over the established `PluginHelper` implementation | -| `app/adapters/system/plugin/` | Plugin package and dependency I/O (`package.py`, `dependency.py`) | +| `app/adapters/external/plugin/client.py` | Plugin-market transport, index/release queries and local-repository candidate resolution; `PluginHelper` is only the stable compatibility facade that delegates here | +| `app/adapters/system/plugin/` | Plugin package, dependency and health I/O (`package.py`, `dependency.py`, `health.py`); `PluginPackageManager` is the unique physical package mutation owner | | `app/db/adapters/` | SQLAlchemy implementations of Application-owned persistence Protocols | Generic protocol transport belongs in `adapters/network`; a named product or @@ -878,7 +878,11 @@ driven workflow registration. | `app/application/chain/context.py` | Injectable Chain dependencies, no-argument compatibility provider and legacy Transfer command Port | | `app/startup/lifecycle/components.py` | Declarative normal/safe-mode lifecycle manifest, ordering and timeout budgets | | `app/runtime/extensions/module_manager.py` | Module discovery and lifecycle | -| `app/runtime/extensions/plugin_manager.py` | Plugin discovery and lifecycle | +| `app/runtime/extensions/plugin_manager.py` | Stable PluginManager ABI facade plus plugin discovery/lifecycle entrypoints; its constructor only consumes the startup-injected Runtime factory and does not build an environment or individual owner | +| `app/runtime/extensions/plugin/runtime.py` | Frozen typed aggregate and construction primitive for plugin registry, lifecycle, catalog, dependency, monitor, projection, sync and package-system owners; it does not choose concrete startup dependencies | +| `app/runtime/extensions/plugin/system.py` | Startup-injected market/package/dependency system-port facade, including delegation to the unique physical package deletion owner; it does not construct concrete adapters | +| `app/runtime/extensions/plugin/dependency.py` | Virtual-instance dependency classification and runtime-status persistence owner | +| `app/startup/initializers/plugins.py` | Plugin Runtime dependency-graph composition, concrete system/storage/catalog ports and lifecycle initialization | | `app/runtime/extensions/plugin/monitor.py` | Plugin file-change aggregation and monitor-thread lifecycle | | `app/runtime/extensions/plugin/projection.py` | Plugin commands, APIs, services, modules and actions projected from a running-registry snapshot | | `app/runtime/extensions/plugin/storage.py` | Injected plugin configuration/data persistence port; runtime code does not import DB Oper classes | @@ -893,9 +897,11 @@ driven workflow registration. | `app/application/scheduling.py` | Scheduler runtime port consumed by API/Agent/application commands | | `app/application/server/report.py` | Server reporting use cases over injected local readers and transport callbacks | | `app/application/server/share.py` | Server sharing use cases over injected repositories and transport callbacks | -| `app/adapters/external/plugin/client.py` | Plugin-market read adapter and cache-refresh boundary | -| `app/adapters/system/plugin/package.py` | Plugin package installation adapter | -| `app/adapters/system/plugin/dependency.py` | Plugin dependency inspection and installation adapter | +| `app/domain/plugin.py` | Pure local-source, generation-compatibility and Release-install-plan rules | +| `app/adapters/external/plugin/client.py` | `PluginMarketTransport`, the unique market transport, index/Release and local-candidate owner | +| `app/adapters/system/plugin/package.py` | `PluginPackageManager`, the unique install, checkpoint, backup, restore and physical-delete owner | +| `app/adapters/system/plugin/dependency.py` | `PluginDependencyInstaller`, the dependency manifest, missing-package inspection and installation owner | +| `app/adapters/system/plugin/health.py` | `PluginRuntimeHealth`, the runtime-environment guard, pre/post-install check and recovery owner | | `app/runtime/extensions/resource.py` | Data-only managed-resource registry and sync/async lifecycle adapters | | `app/runtime/resources.py` | Lightweight acquisition, state observation and shutdown facade | | `app/foundation/reflection.py` | Generic reflection and Python module discovery | @@ -910,7 +916,7 @@ driven workflow registration. | `app/adapters/system/fsproxy.py` | Timeout-guarded local filesystem operations in a killable subprocess (with colocated `fsworker.py`) | | `app/adapters/external/wechat_crypt.py` | WeChat enterprise-message XML encryption/decryption protocol | | `app/application/rules.py` | Rule domain: user rule-group config access (`RuleHelper`), built-in torrent filter rule set and rule parser | -| `app/adapters/external/market.py` | Plugin repository discovery and installation | +| `app/adapters/external/market.py` | Exact legacy `PluginHelper` compatibility facade and install Gateway; canonical market behavior lives in `plugin/client.py` and host code must not import this facade | | `app/application/security/url.py` | URL/path validation, SSRF protection and signed image policy | | `app/application/mediaserver.py` | Configured media-server discovery and identity matching | | `app/runtime/compat/manifest.py` | Exact legacy-to-canonical import manifest | diff --git a/tests/conftest.py b/tests/conftest.py index d8616c34f..af2fcbb62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -207,15 +207,53 @@ def configure_plugin_system_services(): from app.application.plugin.runtime import configure_plugin_runtime from app.runtime.cache import AsyncFileCache, FileCache from app.runtime.events import EventManager + from app.runtime.extensions import plugin_manager as plugin_manager_module from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher from app.runtime.extensions.module_manager import ModuleManager - from app.runtime.extensions.plugin_manager import PluginManager + from app.runtime.extensions.plugin.runtime import ( + PluginRuntimeEnvironment, + build_plugin_runtime, + ) + from app.runtime.extensions.plugin.storage import get_plugin_storage + from app.runtime.extensions.plugin.system import get_plugin_system + from app.runtime.extensions.plugin_manager import ( + PluginManager, + reset_plugin_runtime_factory, + ) from app.runtime.extensions.service_config import ServiceConfigHelper configure_service_directory( configs=ServiceConfigHelper.get_configs, modules=lambda module_type: ModuleManager().get_running_type_modules(module_type), ) + def build_test_plugin_runtime(host): + """在 pytest 组合根装配直接构造 Manager 所需的隔离 Runtime。""" + return build_plugin_runtime( + host, + PluginRuntimeEnvironment( + plugins_root=settings.ROOT_PATH / "app" / "plugins", + storage=get_plugin_storage, + system=get_plugin_system, + catalog_factory=lambda mapper: ( + plugin_manager_module._plugin_catalog_factory(mapper) + ), + import_preparer=lambda **kwargs: ( + plugin_manager_module._legacy_plugin_import_preparer(**kwargs) + ), + import_scanner=lambda **kwargs: ( + plugin_manager_module._legacy_import_scanner(**kwargs) + ), + auth_level=lambda: plugin_manager_module._site_auth_level_provider(), + remote_entry=host.get_plugin_remote_entry, + development=lambda: bool( + plugin_manager_module.get_runtime_setting('DEV') + ), + logger=plugin_manager_module.logger, + ), + tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS, + ) + + plugin_manager_module.configure_plugin_runtime_factory(build_test_plugin_runtime) configure_plugin_runtime(lambda: PluginManager()) configure_module_runtime(lambda: ModuleManager()) from app.application.site.health import SiteHealthService, configure_site_health_service @@ -440,11 +478,12 @@ def configure_plugin_system_services(): moviepilot_tool_manager.set_data_context(agent_data_context) Scheduler().configure_agent_tasks(agent_task_repository) - from app.adapters.external.market import ( + from app.adapters.external.plugin.client import ( VERSION_BACKWARD_COMPATIBLE_FLAGS, - PluginHelper, + PluginMarketClient, + PluginMarketTransport, + PluginPackageSourceClient, ) - from app.adapters.external.plugin.client import PluginMarketClient from app.adapters.system.plugin.dependency import PluginDependencyInstaller from app.adapters.system.plugin.manifest import dependency_manifest_status from app.adapters.system.plugin.package import PluginPackageManager @@ -454,12 +493,14 @@ def configure_plugin_system_services(): reset_plugin_system, ) - helper = PluginHelper() + market_transport = PluginMarketTransport() configure_plugin_system( PluginSystemServices( - market=PluginMarketClient(helper), - package=PluginPackageManager(helper), - dependency=PluginDependencyInstaller(helper), + market=PluginMarketClient(market_transport), + package=PluginPackageManager( + source=PluginPackageSourceClient(market_transport), + ), + dependency=PluginDependencyInstaller(), dependency_manifest_status=dependency_manifest_status, compatible_flags=lambda flag: [flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) if flag else [], frozen=lambda: False, @@ -477,6 +518,7 @@ def configure_plugin_system_services(): reset_chain_network_ports() reset_chain_ports() reset_plugin_system() + reset_plugin_runtime_factory() class DbHarness: diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index e968225c6..7304b7971 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,12 +1,12 @@ { "application": { - "covered_lines": 11487, - "percent": 80.75, - "statements": 14226 + "covered_lines": 12459, + "percent": 80.92, + "statements": 15397 }, "domain": { - "covered_lines": 3394, - "percent": 79.32, - "statements": 4279 + "covered_lines": 3511, + "percent": 79.87, + "statements": 4396 } } diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index fd32014c7..1391c350c 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -99,9 +99,9 @@ "bindings": [ "import:httpx2 as httpx2" ], - "fingerprint": "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f", + "fingerprint": "2d249b0906bc7f8fa29f569d6bcc5667d4290a551b1a06bcdf85e09ec50a793d", "kind": "raw_transport", - "source": "app.adapters.external.market", + "source": "app.adapters.external.plugin.client", "target": "httpx2", "uses": [] }, @@ -109,9 +109,9 @@ "bindings": [ "from:requests.Response as Response" ], - "fingerprint": "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3", + "fingerprint": "b5239e7eee9c23569b7fec66791e56bcc226db86a8d09ee7fc58d3ba8bc14f09", "kind": "raw_transport", - "source": "app.adapters.external.market", + "source": "app.adapters.external.plugin.client", "target": "requests", "uses": [] }, @@ -1119,8 +1119,8 @@ "runtime_only": true } }, - "edge_count": 8038, - "edge_sha256": "b54be324bc5ced15bb1b68c118912a1038de5dc099fb4b70fec5e4a78674caf0", + "edge_count": 8075, + "edge_sha256": "53bd3c6677bcb68c50fa25ebe81a632e5501d87e257dcdb4ea665fa47f6b5a18", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -1155,38 +1155,38 @@ "app.adapters.external.location -> app.adapters.network", "app.adapters.external.location -> app.adapters.network.http", "app.adapters.external.market -> app.adapters", - "app.adapters.external.market -> app.adapters.network", - "app.adapters.external.market -> app.adapters.network.http", + "app.adapters.external.market -> app.adapters.external", + "app.adapters.external.market -> app.adapters.external.plugin", + "app.adapters.external.market -> app.adapters.external.plugin.client", "app.adapters.external.market -> app.adapters.system", - "app.adapters.external.market -> app.adapters.system.host", - "app.adapters.external.market -> app.adapters.system.package", "app.adapters.external.market -> app.adapters.system.plugin", - "app.adapters.external.market -> app.adapters.system.plugin.manifest", + "app.adapters.external.market -> app.adapters.system.plugin.dependency", + "app.adapters.external.market -> app.adapters.system.plugin.health", "app.adapters.external.market -> app.foundation", - "app.adapters.external.market -> app.foundation.environment", "app.adapters.external.market -> app.foundation.singleton", - "app.adapters.external.market -> app.foundation.url", - "app.adapters.external.market -> app.foundation.version", "app.adapters.external.market -> app.runtime", - "app.adapters.external.market -> app.runtime.cache", - "app.adapters.external.market -> app.runtime.dependencies", - "app.adapters.external.market -> app.runtime.dependencies.profile", - "app.adapters.external.market -> app.runtime.execution", - "app.adapters.external.market -> app.runtime.log", "app.adapters.external.market -> app.runtime.observability", "app.adapters.external.market -> app.runtime.settings", - "app.adapters.external.market -> app.runtime.tasks", - "app.adapters.external.market -> app.runtime.version", "app.adapters.external.ocr -> app.adapters", "app.adapters.external.ocr -> app.adapters.network", "app.adapters.external.ocr -> app.adapters.network.http", "app.adapters.external.ocr -> app.runtime", "app.adapters.external.ocr -> app.runtime.settings", "app.adapters.external.plugin.client -> app.adapters", - "app.adapters.external.plugin.client -> app.adapters.external", - "app.adapters.external.plugin.client -> app.adapters.external.market", + "app.adapters.external.plugin.client -> app.adapters.network", + "app.adapters.external.plugin.client -> app.adapters.network.http", + "app.adapters.external.plugin.client -> app.domain", + "app.adapters.external.plugin.client -> app.domain.plugin", + "app.adapters.external.plugin.client -> app.foundation", + "app.adapters.external.plugin.client -> app.foundation.environment", + "app.adapters.external.plugin.client -> app.foundation.url", + "app.adapters.external.plugin.client -> app.foundation.version", "app.adapters.external.plugin.client -> app.runtime", "app.adapters.external.plugin.client -> app.runtime.cache", + "app.adapters.external.plugin.client -> app.runtime.log", + "app.adapters.external.plugin.client -> app.runtime.settings", + "app.adapters.external.plugin.client -> app.runtime.tasks", + "app.adapters.external.plugin.client -> app.runtime.version", "app.adapters.external.server -> app.adapters", "app.adapters.external.server -> app.adapters.network", "app.adapters.external.server -> app.adapters.network.http", @@ -1253,27 +1253,42 @@ "app.adapters.system.package -> app.runtime.dependencies", "app.adapters.system.package -> app.runtime.dependencies.profile", "app.adapters.system.plugin.dependency -> app.adapters", - "app.adapters.system.plugin.dependency -> app.adapters.external", - "app.adapters.system.plugin.dependency -> app.adapters.external.market", "app.adapters.system.plugin.dependency -> app.adapters.system", "app.adapters.system.plugin.dependency -> app.adapters.system.plugin", + "app.adapters.system.plugin.dependency -> app.adapters.system.plugin.health", "app.adapters.system.plugin.dependency -> app.adapters.system.plugin.manifest", "app.adapters.system.plugin.dependency -> app.runtime", "app.adapters.system.plugin.dependency -> app.runtime.log", "app.adapters.system.plugin.dependency -> app.runtime.settings", + "app.adapters.system.plugin.health -> app.adapters", + "app.adapters.system.plugin.health -> app.adapters.system", + "app.adapters.system.plugin.health -> app.adapters.system.host", + "app.adapters.system.plugin.health -> app.adapters.system.package", + "app.adapters.system.plugin.health -> app.adapters.system.plugin", + "app.adapters.system.plugin.health -> app.adapters.system.plugin.manifest", + "app.adapters.system.plugin.health -> app.runtime", + "app.adapters.system.plugin.health -> app.runtime.dependencies", + "app.adapters.system.plugin.health -> app.runtime.dependencies.profile", + "app.adapters.system.plugin.health -> app.runtime.execution", + "app.adapters.system.plugin.health -> app.runtime.log", + "app.adapters.system.plugin.health -> app.runtime.settings", "app.adapters.system.plugin.manifest -> app.runtime", "app.adapters.system.plugin.manifest -> app.runtime.log", "app.adapters.system.plugin.package -> app.adapters", - "app.adapters.system.plugin.package -> app.adapters.external", - "app.adapters.system.plugin.package -> app.adapters.external.market", "app.adapters.system.plugin.package -> app.adapters.system", "app.adapters.system.plugin.package -> app.adapters.system.host", + "app.adapters.system.plugin.package -> app.adapters.system.plugin", + "app.adapters.system.plugin.package -> app.adapters.system.plugin.health", + "app.adapters.system.plugin.package -> app.adapters.system.plugin.manifest", + "app.adapters.system.plugin.package -> app.domain", + "app.adapters.system.plugin.package -> app.domain.plugin", "app.adapters.system.plugin.package -> app.runtime", "app.adapters.system.plugin.package -> app.runtime.dependencies", "app.adapters.system.plugin.package -> app.runtime.dependencies.native", "app.adapters.system.plugin.package -> app.runtime.execution", "app.adapters.system.plugin.package -> app.runtime.log", "app.adapters.system.plugin.package -> app.runtime.settings", + "app.adapters.system.plugin.package -> app.runtime.version", "app.adapters.system.resource -> app.adapters", "app.adapters.system.resource -> app.adapters.network", "app.adapters.system.resource -> app.adapters.network.http", @@ -1711,7 +1726,8 @@ "app.agent.tools.impl._music_utils -> app.schemas.types", "app.agent.tools.impl._plugin_tool_utils -> app.adapters", "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external", - "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.market", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.plugin", + "app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.plugin.client", "app.agent.tools.impl._plugin_tool_utils -> app.agent", "app.agent.tools.impl._plugin_tool_utils -> app.agent.tools", "app.agent.tools.impl._plugin_tool_utils -> app.agent.tools.base", @@ -1724,8 +1740,6 @@ "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.routes", "app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.runtime", "app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling", - "app.agent.tools.impl._plugin_tool_utils -> app.runtime", - "app.agent.tools.impl._plugin_tool_utils -> app.runtime.settings", "app.agent.tools.impl._plugin_tool_utils -> app.schemas", "app.agent.tools.impl._plugin_tool_utils -> app.schemas.plugin", "app.agent.tools.impl._plugin_tool_utils -> app.schemas.types", @@ -3386,7 +3400,8 @@ "app.api.endpoints.subscribe -> app.schemas.workflow", "app.api.endpoints.system -> app.adapters", "app.api.endpoints.system -> app.adapters.external", - "app.api.endpoints.system -> app.adapters.external.market", + "app.api.endpoints.system -> app.adapters.external.plugin", + "app.api.endpoints.system -> app.adapters.external.plugin.client", "app.api.endpoints.system -> app.adapters.external.server", "app.api.endpoints.system -> app.adapters.network", "app.api.endpoints.system -> app.adapters.network.http", @@ -3892,6 +3907,8 @@ "app.application.plugin.admission -> app.application.plugin.identity", "app.application.plugin.admission -> app.application.plugin.inventory", "app.application.plugin.admission -> app.application.plugin.source", + "app.application.plugin.admission -> app.domain", + "app.application.plugin.admission -> app.domain.plugin", "app.application.plugin.catalog -> app.application", "app.application.plugin.catalog -> app.application.plugin", "app.application.plugin.catalog -> app.application.plugin.identity", @@ -3940,6 +3957,8 @@ "app.application.plugin.inventory -> app.application.plugin", "app.application.plugin.inventory -> app.application.plugin.identity", "app.application.plugin.inventory -> app.application.plugin.source", + "app.application.plugin.inventory -> app.domain", + "app.application.plugin.inventory -> app.domain.plugin", "app.application.plugin.inventory -> app.foundation", "app.application.plugin.inventory -> app.foundation.environment", "app.application.plugin.migration -> app.application", @@ -8029,7 +8048,10 @@ "app.runtime.extensions.plugin.dependency -> app.runtime", "app.runtime.extensions.plugin.dependency -> app.runtime.extensions", "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.registry", "app.runtime.extensions.plugin.dependency -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.dependency -> app.schemas", + "app.runtime.extensions.plugin.dependency -> app.schemas.plugin", "app.runtime.extensions.plugin.lifecycle -> app.runtime", "app.runtime.extensions.plugin.lifecycle -> app.runtime.observability", "app.runtime.extensions.plugin.lifecycle -> app.schemas", @@ -8059,6 +8081,31 @@ "app.runtime.extensions.plugin.projection -> app.schemas.plugin", "app.runtime.extensions.plugin.registry -> app.schemas", "app.runtime.extensions.plugin.registry -> app.schemas.plugin", + "app.runtime.extensions.plugin.runtime -> app.foundation", + "app.runtime.extensions.plugin.runtime -> app.foundation.crypto", + "app.runtime.extensions.plugin.runtime -> app.foundation.version", + "app.runtime.extensions.plugin.runtime -> app.runtime", + "app.runtime.extensions.plugin.runtime -> app.runtime.events", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.access", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.admission", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.catalog", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.clone", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.dependency", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.lifecycle", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.loader", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.metadata", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.monitor", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.paths", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.projection", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.registry", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.storage", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.sync", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.tools", + "app.runtime.extensions.plugin.runtime -> app.schemas", + "app.runtime.extensions.plugin.runtime -> app.schemas.types", "app.runtime.extensions.plugin.storage -> app.schemas", "app.runtime.extensions.plugin.storage -> app.schemas.plugin", "app.runtime.extensions.plugin.storage -> app.schemas.types", @@ -8071,30 +8118,19 @@ "app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin.contracts", "app.runtime.extensions.plugin_manager -> app.foundation", - "app.runtime.extensions.plugin_manager -> app.foundation.crypto", "app.runtime.extensions.plugin_manager -> app.foundation.environment", "app.runtime.extensions.plugin_manager -> app.foundation.singleton", - "app.runtime.extensions.plugin_manager -> app.foundation.version", "app.runtime.extensions.plugin_manager -> app.runtime", "app.runtime.extensions.plugin_manager -> app.runtime.events", "app.runtime.extensions.plugin_manager -> app.runtime.execution", "app.runtime.extensions.plugin_manager -> app.runtime.extensions", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.access", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.admission", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.catalog", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.clone", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.dependency", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.lifecycle", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.loader", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.metadata", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.monitor", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.paths", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.projection", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.registry", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.storage", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.sync", - "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.runtime", "app.runtime.extensions.plugin_manager -> app.runtime.extensions.plugin.tools", "app.runtime.extensions.plugin_manager -> app.runtime.log", "app.runtime.extensions.plugin_manager -> app.runtime.observability", @@ -8863,6 +8899,7 @@ "app.startup.initializers.plugins -> app.runtime.extensions", "app.startup.initializers.plugins -> app.runtime.extensions.plugin", "app.startup.initializers.plugins -> app.runtime.extensions.plugin.dependency", + "app.startup.initializers.plugins -> app.runtime.extensions.plugin.runtime", "app.startup.initializers.plugins -> app.runtime.extensions.plugin.storage", "app.startup.initializers.plugins -> app.runtime.extensions.plugin.system", "app.startup.initializers.plugins -> app.runtime.extensions.plugin_manager", @@ -9161,7 +9198,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 947, + "module_count": 950, "modules": [ "app", "app.adapters", @@ -9198,6 +9235,7 @@ "app.adapters.system.package", "app.adapters.system.plugin", "app.adapters.system.plugin.dependency", + "app.adapters.system.plugin.health", "app.adapters.system.plugin.manifest", "app.adapters.system.plugin.package", "app.adapters.system.resource", @@ -9723,6 +9761,7 @@ "app.domain.meta.streamingplatform", "app.domain.meta.words", "app.domain.metainfo", + "app.domain.plugin", "app.domain.scraper", "app.domain.site", "app.domain.title", @@ -9960,6 +9999,7 @@ "app.runtime.extensions.plugin.paths", "app.runtime.extensions.plugin.projection", "app.runtime.extensions.plugin.registry", + "app.runtime.extensions.plugin.runtime", "app.runtime.extensions.plugin.storage", "app.runtime.extensions.plugin.sync", "app.runtime.extensions.plugin.system", diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 453eb2a2d..44af44ccc 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -200,8 +200,8 @@ "owner": "$source", "reason": "运行期 import 仅用于响应类型、协议解析或兼容补丁,uses 为空或不获取网络能力。", "facts": [ - {"source": "app.adapters.external.market", "target": "httpx2", "kind": "raw_transport", "fingerprint": "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f"}, - {"source": "app.adapters.external.market", "target": "requests", "kind": "raw_transport", "fingerprint": "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3"}, + {"source": "app.adapters.external.plugin.client", "target": "httpx2", "kind": "raw_transport", "fingerprint": "2d249b0906bc7f8fa29f569d6bcc5667d4290a551b1a06bcdf85e09ec50a793d"}, + {"source": "app.adapters.external.plugin.client", "target": "requests", "kind": "raw_transport", "fingerprint": "b5239e7eee9c23569b7fec66791e56bcc226db86a8d09ee7fc58d3ba8bc14f09"}, {"source": "app.modules.emby.emby", "target": "requests", "kind": "raw_transport", "fingerprint": "6c4c17fe170226ea1272f5859bc119c2775442cabe8ce2a9edcabaf9c37bec31"}, {"source": "app.modules.filemanager.storages.smb", "target": "smbprotocol", "kind": "network_sdk", "fingerprint": "f02686afd99c59820dffa7b4c2627a0be1ad62980c98a9cd33b7564697842b22"}, {"source": "app.modules.jellyfin.jellyfin", "target": "requests", "kind": "raw_transport", "fingerprint": "5c46d09ca9a4bcc0bae21ca5d554ef09baa3f901c562b27c5f5ee1439c7746b5"}, diff --git a/tests/fixtures/architecture/filename-policy.json b/tests/fixtures/architecture/filename-policy.json new file mode 100644 index 000000000..19204db2e --- /dev/null +++ b/tests/fixtures/architecture/filename-policy.json @@ -0,0 +1,160 @@ +{ + "grandfathered_multiword_files": [ + "app/adapters/external/wechat_crypt.py", + "app/agent/llm/server_tools.py", + "app/agent/middleware/activity_log.py", + "app/agent/middleware/patch_tool_calls.py", + "app/agent/middleware/runtime_config.py", + "app/agent/middleware/tool_selection.py", + "app/agent/policy/secret_fields.py", + "app/agent/prompt/transfer_redo.py", + "app/agent/runtime_loader.py", + "app/agent/tools/impl/_command_safety.py", + "app/agent/tools/impl/_file_write_utils.py", + "app/agent/tools/impl/_filter_rule_utils.py", + "app/agent/tools/impl/_music_utils.py", + "app/agent/tools/impl/_plugin_tool_utils.py", + "app/agent/tools/impl/_system_setting_utils.py", + "app/agent/tools/impl/_terminal_session.py", + "app/agent/tools/impl/_torrent_search_utils.py", + "app/agent/tools/impl/add_custom_filter_rule.py", + "app/agent/tools/impl/add_download_tasks.py", + "app/agent/tools/impl/add_rule_group.py", + "app/agent/tools/impl/add_subscribe.py", + "app/agent/tools/impl/apply_patch.py", + "app/agent/tools/impl/ask_user_choice.py", + "app/agent/tools/impl/browse_webpage.py", + "app/agent/tools/impl/create_agent_task.py", + "app/agent/tools/impl/delete_agent_task.py", + "app/agent/tools/impl/delete_custom_filter_rule.py", + "app/agent/tools/impl/delete_download_history.py", + "app/agent/tools/impl/delete_download_tasks.py", + "app/agent/tools/impl/delete_rule_group.py", + "app/agent/tools/impl/delete_subscribe.py", + "app/agent/tools/impl/delete_transfer_history.py", + "app/agent/tools/impl/edit_file.py", + "app/agent/tools/impl/execute_command.py", + "app/agent/tools/impl/get_recommendations.py", + "app/agent/tools/impl/get_search_results.py", + "app/agent/tools/impl/install_plugin.py", + "app/agent/tools/impl/list_directory.py", + "app/agent/tools/impl/list_slash_commands.py", + "app/agent/tools/impl/query_agent_tasks.py", + "app/agent/tools/impl/query_builtin_filter_rules.py", + "app/agent/tools/impl/query_custom_filter_rules.py", + "app/agent/tools/impl/query_custom_identifiers.py", + "app/agent/tools/impl/query_directory_settings.py", + "app/agent/tools/impl/query_doctor_report.py", + "app/agent/tools/impl/query_download_tasks.py", + "app/agent/tools/impl/query_downloaders.py", + "app/agent/tools/impl/query_episode_schedule.py", + "app/agent/tools/impl/query_installed_plugins.py", + "app/agent/tools/impl/query_library_exists.py", + "app/agent/tools/impl/query_library_latest.py", + "app/agent/tools/impl/query_market_plugins.py", + "app/agent/tools/impl/query_media_detail.py", + "app/agent/tools/impl/query_personas.py", + "app/agent/tools/impl/query_plugin_capabilities.py", + "app/agent/tools/impl/query_plugin_config.py", + "app/agent/tools/impl/query_plugin_data.py", + "app/agent/tools/impl/query_popular_subscribes.py", + "app/agent/tools/impl/query_rule_groups.py", + "app/agent/tools/impl/query_schedulers.py", + "app/agent/tools/impl/query_site_userdata.py", + "app/agent/tools/impl/query_sites.py", + "app/agent/tools/impl/query_subscribe_history.py", + "app/agent/tools/impl/query_subscribe_shares.py", + "app/agent/tools/impl/query_subscribes.py", + "app/agent/tools/impl/query_system_settings.py", + "app/agent/tools/impl/query_transfer_history.py", + "app/agent/tools/impl/query_workflows.py", + "app/agent/tools/impl/read_file.py", + "app/agent/tools/impl/recognize_captcha.py", + "app/agent/tools/impl/recognize_media.py", + "app/agent/tools/impl/reload_plugin.py", + "app/agent/tools/impl/run_agent_task.py", + "app/agent/tools/impl/run_scheduler.py", + "app/agent/tools/impl/run_slash_command.py", + "app/agent/tools/impl/run_workflow.py", + "app/agent/tools/impl/scrape_metadata.py", + "app/agent/tools/impl/search_media.py", + "app/agent/tools/impl/search_person.py", + "app/agent/tools/impl/search_person_credits.py", + "app/agent/tools/impl/search_subscribe.py", + "app/agent/tools/impl/search_torrents.py", + "app/agent/tools/impl/search_web.py", + "app/agent/tools/impl/send_local_file.py", + "app/agent/tools/impl/send_message.py", + "app/agent/tools/impl/send_voice_message.py", + "app/agent/tools/impl/switch_persona.py", + "app/agent/tools/impl/test_site.py", + "app/agent/tools/impl/transfer_file.py", + "app/agent/tools/impl/uninstall_plugin.py", + "app/agent/tools/impl/update_agent_task.py", + "app/agent/tools/impl/update_custom_filter_rule.py", + "app/agent/tools/impl/update_custom_identifiers.py", + "app/agent/tools/impl/update_download_tasks.py", + "app/agent/tools/impl/update_persona_definition.py", + "app/agent/tools/impl/update_plugin_config.py", + "app/agent/tools/impl/update_rule_group.py", + "app/agent/tools/impl/update_site.py", + "app/agent/tools/impl/update_site_cookie.py", + "app/agent/tools/impl/update_subscribe.py", + "app/agent/tools/impl/update_system_settings.py", + "app/agent/tools/impl/write_file.py", + "app/api/openai_utils.py", + "app/api/router_specs.py", + "app/application/torrent_cache.py", + "app/chain/_contracts.py", + "app/chain/_interaction.py", + "app/chain/_messaging.py", + "app/chain/_music.py", + "app/chain/_recognition.py", + "app/db/models/_constraints.py", + "app/db/models/_identity.py", + "app/modules/_base/media_auxiliary.py", + "app/modules/indexer/parser/file_list.py", + "app/modules/indexer/parser/ipt_project.py", + "app/modules/indexer/parser/nexus_audiences.py", + "app/modules/indexer/parser/nexus_hhanclub.py", + "app/modules/indexer/parser/nexus_php.py", + "app/modules/indexer/parser/nexus_project.py", + "app/modules/indexer/parser/nexus_rabbit.py", + "app/modules/indexer/parser/small_horse.py", + "app/modules/indexer/parser/torrent_leech.py", + "app/modules/musicbrainz/music_cache.py", + "app/modules/themoviedb/tmdb_cache.py", + "app/modules/themoviedb/tmdbv3api/as_obj.py", + "app/modules/thetvdb/tvdb_v4_official.py", + "app/runtime/compat/resource_imports.py", + "app/runtime/extensions/host_module_adapter.py", + "app/runtime/extensions/module_manager.py", + "app/runtime/extensions/plugin_manager.py", + "app/runtime/extensions/service_config.py", + "app/testing/network_guard.py", + "app/workflow/actions/add_download.py", + "app/workflow/actions/add_subscribe.py", + "app/workflow/actions/fetch_downloads.py", + "app/workflow/actions/fetch_medias.py", + "app/workflow/actions/fetch_rss.py", + "app/workflow/actions/fetch_torrents.py", + "app/workflow/actions/filter_medias.py", + "app/workflow/actions/filter_torrents.py", + "app/workflow/actions/invoke_plugin.py", + "app/workflow/actions/scan_file.py", + "app/workflow/actions/scrape_file.py", + "app/workflow/actions/send_event.py", + "app/workflow/actions/send_message.py", + "app/workflow/actions/transfer_file.py" + ], + "schema_version": 1, + "scope": { + "excluded": [ + "app/plugins" + ], + "root": "app", + "special": [ + "__init__.py" + ] + } +} diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index fcce4b751..08b0883f7 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -22,28 +22,9 @@ "app/adapters/external/location.py": { "no-untyped-def": 3 }, - "app/adapters/external/market.py": { - "arg-type": 3, - "assignment": 3, - "attr-defined": 4, - "import-untyped": 1, - "misc": 2, - "no-any-return": 8, - "no-untyped-call": 6, - "no-untyped-def": 10, - "operator": 1, - "return-value": 6, - "type-arg": 41, - "union-attr": 2, - "var-annotated": 5 - }, "app/adapters/external/ocr.py": { "arg-type": 1 }, - "app/adapters/external/plugin/client.py": { - "arg-type": 1, - "type-arg": 6 - }, "app/adapters/external/server.py": { "arg-type": 5, "assignment": 1, @@ -113,9 +94,6 @@ "type-arg": 5, "var-annotated": 1 }, - "app/adapters/system/plugin/dependency.py": { - "no-any-return": 3 - }, "app/adapters/system/resource.py": { "no-untyped-call": 1, "no-untyped-def": 2, @@ -3111,13 +3089,11 @@ "type-arg": 4 }, "app/runtime/extensions/plugin_manager.py": { - "arg-type": 2, "assignment": 8, "attr-defined": 1, - "no-any-return": 3, - "no-untyped-call": 4, - "no-untyped-def": 14, - "type-arg": 11 + "no-untyped-call": 1, + "no-untyped-def": 12, + "type-arg": 10 }, "app/runtime/extensions/service_config.py": { "type-arg": 2 @@ -3324,14 +3300,14 @@ "attr-defined": 2, "misc": 1, "no-any-return": 2, - "no-untyped-call": 30, + "no-untyped-call": 28, "no-untyped-def": 6, "return-value": 3 }, "app/startup/initializers/plugins.py": { "arg-type": 5, "no-any-return": 2, - "no-untyped-call": 4, + "no-untyped-call": 2, "no-untyped-def": 5 }, "app/startup/initializers/routers.py": { diff --git a/tests/fixtures/architecture/official-plugin-baseline.json b/tests/fixtures/architecture/official-plugin-baseline.json index e9f479c58..646564bb1 100644 --- a/tests/fixtures/architecture/official-plugin-baseline.json +++ b/tests/fixtures/architecture/official-plugin-baseline.json @@ -1378,6 +1378,19 @@ "response_model": null } ], + "plugins.v2/iqiyidiscover/__init__.py": [ + { + "auth": null, + "endpoint": "iqiyi_discover", + "endpoint_return": "List[schemas.MediaInfo]", + "methods": [ + "GET" + ], + "path": "/iqiyi_discover", + "response_class": null, + "response_model": null + } + ], "plugins.v2/lexiannot/__init__.py": [ { "auth": null, @@ -2892,7 +2905,7 @@ ] }, "get_api": { - "file_count": 117, + "file_count": 118, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -2925,6 +2938,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -3018,7 +3032,7 @@ "files": [] }, "get_command": { - "file_count": 115, + "file_count": 116, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3050,6 +3064,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -3156,7 +3171,7 @@ ] }, "get_form": { - "file_count": 117, + "file_count": 118, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3189,6 +3204,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -3289,7 +3305,7 @@ ] }, "get_page": { - "file_count": 117, + "file_count": 118, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3322,6 +3338,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -3496,7 +3513,7 @@ "files": [] }, "get_state": { - "file_count": 119, + "file_count": 120, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3530,6 +3547,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -3620,7 +3638,7 @@ ] }, "init_plugin": { - "file_count": 117, + "file_count": 118, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3653,6 +3671,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -3742,7 +3761,7 @@ ] }, "stop_service": { - "file_count": 117, + "file_count": 118, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -3775,6 +3794,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -4109,7 +4129,7 @@ ] }, "app.core.cache": { - "file_count": 9, + "file_count": 10, "files": [ "plugins.v2/clashruleprovider/services.py", "plugins.v2/clashruleprovider/state.py", @@ -4117,13 +4137,14 @@ "plugins.v2/imdbsource/imdbapi.py", "plugins.v2/imdbsource/imdbhelper.py", "plugins.v2/imdbsource/officialapi.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/lexiannot/spacyworker.py", "plugins.v2/mediaservermsg/__init__.py" ] }, "app.core.config": { - "file_count": 84, + "file_count": 85, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agentresourceofficer/services/hdhive_openapi.py", @@ -4173,6 +4194,7 @@ "plugins.v2/imdbsource/imdbapi.py", "plugins.v2/imdbsource/imdbhelper.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -4235,7 +4257,7 @@ ] }, "app.core.event": { - "file_count": 63, + "file_count": 64, "files": [ "plugins.v2/agentresourceofficer/feishu_channel.py", "plugins.v2/agenttokens/__init__.py", @@ -4260,6 +4282,7 @@ "plugins.v2/fullscreenposterwall/__init__.py", "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/mediaservermsg/__init__.py", @@ -4695,7 +4718,7 @@ ] }, "app.log": { - "file_count": 128, + "file_count": 129, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agentresourceofficer/feishu_channel.py", @@ -4758,6 +4781,7 @@ "plugins.v2/imdbsource/imdbhelper.py", "plugins.v2/imdbsource/officialapi.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/lexiannot/pipeline.py", @@ -4903,7 +4927,7 @@ ] }, "app.plugins": { - "file_count": 117, + "file_count": 118, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agenttokens/__init__.py", @@ -4936,6 +4960,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/libraryscraper/__init__.py", @@ -5134,7 +5159,7 @@ ] }, "app.schemas": { - "file_count": 56, + "file_count": 57, "files": [ "plugins.v2/autoclean/__init__.py", "plugins.v2/blurayremux/__init__.py", @@ -5152,6 +5177,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/lexiannot/pipeline.py", @@ -5213,7 +5239,7 @@ ] }, "app.schemas.types": { - "file_count": 95, + "file_count": 96, "files": [ "plugins.v2/agentresourceofficer/__init__.py", "plugins.v2/agentresourceofficer/feishu_channel.py", @@ -5243,6 +5269,7 @@ "plugins.v2/hrblocker/__init__.py", "plugins.v2/imdbsource/__init__.py", "plugins.v2/imdbsource/imdbhelper.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/lexiannot/__init__.py", "plugins.v2/lexiannot/pipeline.py", @@ -5509,7 +5536,7 @@ ] }, "app.utils.http": { - "file_count": 62, + "file_count": 63, "files": [ "plugins.v2/agentresourceofficer/feishu_channel.py", "plugins.v2/animeupscale/__init__.py", @@ -5544,6 +5571,7 @@ "plugins.v2/imdbsource/imdbhelper.py", "plugins.v2/imdbsource/officialapi.py", "plugins.v2/invitessignin/__init__.py", + "plugins.v2/iqiyidiscover/__init__.py", "plugins.v2/iyuuautoseed/__init__.py", "plugins.v2/iyuuautoseed/iyuu_helper.py", "plugins.v2/lexiannot/__init__.py", @@ -5677,9 +5705,9 @@ } }, "provenance": { - "head": "aa40b961a2093474a77edd9eed88844ff07425ac", - "python_file_count": 284, - "source_sha256": "c9621ee326c82a47f7c79725249b8adb3cf5176d604e868547a913e6cd77c5cb" + "head": "d5a786882cd83060f8acc6b42f710f00e48a99d4", + "python_file_count": 285, + "source_sha256": "3a47cb221cbcc48c92aaa05838a98d23a13b66d9c3742cac73b1233d88d23fde" }, "schema_version": 3, "scope": { diff --git a/tests/fixtures/architecture/ruff-baseline.json b/tests/fixtures/architecture/ruff-baseline.json index de3a2d993..611085f32 100644 --- a/tests/fixtures/architecture/ruff-baseline.json +++ b/tests/fixtures/architecture/ruff-baseline.json @@ -1173,9 +1173,6 @@ "tests/test_plugin_dependency_service.py": { "I001": 1 }, - "tests/test_plugin_helper.py": { - "F401": 2 - }, "tests/test_plugin_lifecycle_status.py": { "F401": 1 }, @@ -1212,9 +1209,6 @@ "tests/test_resource_v3.py": { "I001": 1 }, - "tests/test_runtime_execution.py": { - "I001": 1 - }, "tests/test_rust_accel_toggle.py": { "I001": 1 }, diff --git a/tests/test_agent_plugin_tools.py b/tests/test_agent_plugin_tools.py index f0bb1841d..6c93c816a 100644 --- a/tests/test_agent_plugin_tools.py +++ b/tests/test_agent_plugin_tools.py @@ -435,6 +435,45 @@ def test_sealed_agent_uninstall_rejects_before_persistence() -> None: config_oper.async_set.assert_not_called() +def test_agent_clone_uninstall_delegates_directory_removal_to_package_owner() -> None: + """Agent 分身卸载与 HTTP 入口共用唯一包文件删除 owner。""" + plugin_manager = MagicMock() + plugin_manager.get_plugin_instance.return_value = None + plugin_manager.get_plugin_source_instances.return_value = [] + plugin_manager.plugins = {"DemoPluginwork": MagicMock(is_clone=True)} + plugin_manager.remove_plugin_package.return_value = True + config_oper = MagicMock() + config_oper.get.return_value = ["DemoPluginwork"] + config_oper.async_set = AsyncMock() + blocking = AsyncMock(return_value=True) + + with ( + patch( + "app.agent.tools.impl._plugin_tool_utils.get_plugin_manager", + return_value=plugin_manager, + ), + patch( + "app.agent.tools.impl._plugin_tool_utils.get_configured_system_config", + return_value=config_oper, + ), + patch( + "app.application.plugin.folders.remove_plugin_from_folders", + ), + patch("app.application.plugin.routes.remove_plugin_api"), + patch("app.application.scheduling.remove_plugin_job"), + patch("app.agent.tools.base.run_agent_blocking", blocking), + ): + result = asyncio.run(uninstall_plugin_runtime("DemoPluginwork")) + + assert result == {"was_clone": True, "clone_files_removed": True} + blocking.assert_awaited_once_with( + "plugin", + plugin_manager.remove_plugin_package, + "DemoPluginwork", + ) + assert "DemoPluginwork" not in plugin_manager.plugins + + def test_query_plugin_data_truncates_large_payload() -> None: """ 查询插件数据会截断超长内容并返回预览。 diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index 0effe763f..14fba8b6f 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -74,6 +74,9 @@ def test_architecture_documents_match_generated_quality_metrics() -> None: ruff = json.loads( (BASELINE_ROOT / "ruff-baseline.json").read_text(encoding="utf-8") ) + mypy = json.loads( + (BASELINE_ROOT / "mypy-baseline.json").read_text(encoding="utf-8") + ) coverage = json.loads( (BASELINE_ROOT / "coverage-baseline.json").read_text(encoding="utf-8") ) @@ -97,12 +100,18 @@ def test_architecture_documents_match_generated_quality_metrics() -> None: for diagnostics in ruff.values() for count in diagnostics.values() ) + mypy_count = sum( + count + for diagnostics in mypy.values() + for count in diagnostics.values() + ) application_coverage = coverage["application"]["percent"] domain_coverage = coverage["domain"]["percent"] event_facts = runtime["event_facts"] assert edge_count in overview assert f"{dependency['module_count']} / {edge_count}" in checklist + assert f"全量 mypy 历史债务 | {mypy_count:,} / {len(mypy)} 文件" in checklist assert f"Ruff 历史诊断 | {ruff_count}" in checklist assert f"Application {application_coverage:.2f}%" in checklist assert f"Domain {domain_coverage:.2f}%" in checklist diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py index b18dfed2b..990ce9b18 100644 --- a/tests/test_architecture_egress.py +++ b/tests/test_architecture_egress.py @@ -70,8 +70,8 @@ FROZEN_EGRESS_EDGES_BY_REASON = { ("app.doctor.checks", "urllib.request"), }, "type_or_compat_only": { - ("app.adapters.external.market", "httpx2"), - ("app.adapters.external.market", "requests"), + ("app.adapters.external.plugin.client", "httpx2"), + ("app.adapters.external.plugin.client", "requests"), ("app.modules.emby.emby", "requests"), ("app.modules.filemanager.storages.smb", "smbprotocol"), ("app.modules.jellyfin.jellyfin", "requests"), @@ -94,8 +94,8 @@ FROZEN_EGRESS_REASON_BY_EDGE = { } FROZEN_EGRESS_FINGERPRINT_BY_EDGE = { ("app.adapters.cache.redis", "redis"): "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81", - ("app.adapters.external.market", "httpx2"): "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f", - ("app.adapters.external.market", "requests"): "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3", + ("app.adapters.external.plugin.client", "httpx2"): "2d249b0906bc7f8fa29f569d6bcc5667d4290a551b1a06bcdf85e09ec50a793d", + ("app.adapters.external.plugin.client", "requests"): "b5239e7eee9c23569b7fec66791e56bcc226db86a8d09ee7fc58d3ba8bc14f09", ("app.adapters.network.browser", "cloakbrowser"): "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173", ("app.adapters.network.doh", "socket.getaddrinfo"): "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71", ("app.adapters.network.doh", "urllib.request"): "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b", diff --git a/tests/test_legacy_plugin_resource_imports.py b/tests/test_legacy_plugin_resource_imports.py index 141941c2d..00d6e6595 100644 --- a/tests/test_legacy_plugin_resource_imports.py +++ b/tests/test_legacy_plugin_resource_imports.py @@ -15,8 +15,8 @@ from app.runtime.compat.resource_imports import ( PluginResourceImportScanError, scan_plugin_resource_imports, ) -from app.runtime.extensions import plugin_manager as plugin_manager_module -from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.log import logger from app.startup.initializers import plugins as plugins_initializer _HEADED_CLOAKBROWSER_ENTRYPOINTS = ( @@ -275,7 +275,7 @@ def test_scanner_honors_python_source_encoding_cookie(tmp_path: Path) -> None: def _fake_plugin_module(module_name: str) -> ModuleType: - """构造满足 PluginManager 类发现合同的内存模块。""" + """构造满足插件 Loader 类发现合同的内存模块。""" module = ModuleType(module_name) plugin_type = type( module_name.rsplit(".", maxsplit=1)[-1].title(), @@ -314,28 +314,14 @@ def test_plugin_preparer_runs_before_import_in_non_debug_and_isolates_failures( events.append(("import", plugin_id)) return _fake_plugin_module(module_name) - monkeypatch.setattr( - plugin_manager_module, - "get_runtime_setting", - lambda key, default=None: { - "ROOT_PATH": tmp_path, - "DEBUG": False, - "DEV": False, - }.get(key, default), - ) - monkeypatch.setattr( - plugin_manager_module, - "_legacy_plugin_import_preparer", - prepare, - ) - monkeypatch.setattr( - plugin_manager_module, - "_legacy_import_scanner", - lambda **_kwargs: None, - ) monkeypatch.setattr(importlib, "import_module", import_plugin) - plugins = PluginManager._load_selective_plugins( + plugins = PluginLoader( + plugins_root=plugins_root, + import_preparer=prepare, + import_scanner=lambda **_kwargs: None, + log=logger, + ).load( None, ["ScanFailed", "ResourceFailed", "Healthy"], lambda plugin_type: hasattr(plugin_type, "init_plugin"), diff --git a/tests/test_lifecycle_shutdown.py b/tests/test_lifecycle_shutdown.py index 128974262..8b7a4e365 100644 --- a/tests/test_lifecycle_shutdown.py +++ b/tests/test_lifecycle_shutdown.py @@ -547,11 +547,12 @@ def test_lifespan_waits_for_uncancellable_plugin_settlement_before_shutdown( assert order[:2] == ["settled", "backup"] -def test_lifespan_configures_plugin_services_before_restore(monkeypatch): - """插件恢复依赖的外部系统服务必须先于恢复阶段完成装配。""" +def test_lifespan_configures_plugin_services_before_modules_and_restore(monkeypatch): + """插件 Runtime 必须先于模块 Chain 上下文和恢复阶段完成装配。""" shutdown_steps = _patch_lifespan(monkeypatch) order = [] lifecycle.configure_plugin_services.side_effect = lambda: order.append("configure") + lifecycle.init_modules.side_effect = lambda: order.append("modules") lifecycle.get_plugin_installation_recovery.return_value.replay.side_effect = ( lambda: order.append("replay") ) @@ -565,7 +566,7 @@ def test_lifespan_configures_plugin_services_before_restore(monkeypatch): asyncio.run(run_lifespan()) - assert order == ["configure", "replay", "restore"] + assert order == ["configure", "modules", "replay", "restore"] _assert_completed_once(shutdown_steps["close_http"]) @@ -670,6 +671,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None: "数据库引擎预热", "数据库连接预算", "路由", + "插件服务装配", "模块服务", "消息队列", "插件备份恢复", @@ -717,6 +719,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None: "数据库引擎预热", "数据库连接预算", "路由", + "插件服务装配", "模块服务", "消息队列", "AI智能体会话", diff --git a/tests/test_plugin_backup_restore.py b/tests/test_plugin_backup_restore.py index be90783bd..4c79a02f7 100644 --- a/tests/test_plugin_backup_restore.py +++ b/tests/test_plugin_backup_restore.py @@ -3,9 +3,9 @@ import errno from dataclasses import replace from pathlib import Path -from types import SimpleNamespace -from app.adapters.external import market as market_module +from app.adapters.system.plugin import package as package_module +from app.adapters.system.plugin.package import PluginPackageManager from app.chain import system as system_module from app.chain.system import SystemChain from app.startup.initializers import network as network_initializer @@ -40,25 +40,30 @@ def _patch_docker_paths(monkeypatch, tmp_path: Path, *, reset: bool) -> Path: return runtime_dir -def _patch_market_paths(monkeypatch, tmp_path: Path) -> tuple[Path, Path]: +def _patch_package_paths( + monkeypatch, + tmp_path: Path, +) -> tuple[PluginPackageManager, Path, Path]: """把插件更新后的持久化备份路径隔离到临时目录。""" plugin_root = tmp_path / "app" / "plugins" config_dir = tmp_path / "config" plugin_root.mkdir(parents=True) config_dir.mkdir(parents=True) - monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root) - runtime_settings = SimpleNamespace(CONFIG_PATH=config_dir) monkeypatch.setattr( - market_module, + package_module, "get_runtime_setting", - lambda key, default=None: getattr(runtime_settings, key, default), + lambda key, default=None: config_dir if key == "CONFIG_PATH" else default, ) monkeypatch.setattr( - market_module.SystemUtils, + package_module.SystemUtils, "is_docker", staticmethod(lambda: True), ) - return plugin_root, config_dir / "plugins_backup" + return ( + PluginPackageManager(plugin_root=plugin_root), + plugin_root, + config_dir / "plugins_backup", + ) def _write_plugin(root: Path, plugin_id: str, filename: str, content: str) -> Path: @@ -314,13 +319,13 @@ def test_backup_does_not_overwrite_failed_restore_snapshot(monkeypatch, tmp_path def test_market_refresh_replaces_snapshot_and_removes_stale_files(monkeypatch, tmp_path): """插件更新成功后应刷新对应持久化快照。""" - plugin_root, backup_root = _patch_market_paths(monkeypatch, tmp_path) + package, plugin_root, backup_root = _patch_package_paths(monkeypatch, tmp_path) backup_dir = backup_root / "demo" _write_plugin(plugin_root, "demo", "plugin.py", "new") _write_plugin(backup_root, "demo", "plugin.py", "old") _write_plugin(backup_root, "demo", "stale.py", "stale") - assert market_module.PluginHelper.refresh_persistent_plugin_backup("demo") is True + assert package.refresh_persistent_backup("demo") is True assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "new" assert not (backup_dir / "stale.py").exists() @@ -328,7 +333,7 @@ def test_market_refresh_replaces_snapshot_and_removes_stale_files(monkeypatch, t def test_market_refresh_failure_preserves_previous_snapshot(monkeypatch, tmp_path): """插件更新备份失败时应继续保留旧快照。""" - plugin_root, backup_root = _patch_market_paths(monkeypatch, tmp_path) + package, plugin_root, backup_root = _patch_package_paths(monkeypatch, tmp_path) backup_dir = backup_root / "demo" _write_plugin(plugin_root, "demo", "plugin.py", "new") _write_plugin(backup_root, "demo", "plugin.py", "old") @@ -336,7 +341,7 @@ def test_market_refresh_failure_preserves_previous_snapshot(monkeypatch, tmp_pat def fail_copy(*_args, **_kwargs): raise OSError("copy failed") - monkeypatch.setattr(market_module.shutil, "copytree", fail_copy) + monkeypatch.setattr(package_module.shutil, "copytree", fail_copy) - assert market_module.PluginHelper.refresh_persistent_plugin_backup("demo") is False + assert package.refresh_persistent_backup("demo") is False assert (backup_dir / "plugin.py").read_text(encoding="utf-8") == "old" diff --git a/tests/test_plugin_compat_governance.py b/tests/test_plugin_compat_governance.py new file mode 100644 index 000000000..0b5b459cd --- /dev/null +++ b/tests/test_plugin_compat_governance.py @@ -0,0 +1,214 @@ +import ast +import importlib +import json +import re +from pathlib import Path + +from app.runtime.compat.manifest import MODULE_ALIASES, PACKAGE_ALIASES + +PROJECT_ROOT = Path(__file__).parents[1] +APP_ROOT = PROJECT_ROOT / "app" +OFFICIAL_PLUGIN_BASELINE = ( + PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "official-plugin-baseline.json" +) +FILENAME_POLICY = PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "filename-policy.json" +PLUGIN_COMPONENT_PACKAGES = ( + APP_ROOT / "adapters" / "external" / "plugin", + APP_ROOT / "adapters" / "system" / "plugin", + APP_ROOT / "application" / "plugin", + APP_ROOT / "runtime" / "extensions" / "plugin", +) +AUDITED_PLUGIN_MANAGER_MEMBERS = { + "get_plugin_config", + "get_plugin_ids", + "plugins", + "running_plugins", +} +DELEGATED_PLUGIN_CATALOG_METHODS = { + "async_get_online_plugin_candidates", + "async_get_online_plugins", + "async_get_plugins_from_market", + "get_installed_plugins", + "get_local_plugin_version", + "get_local_plugins", + "get_local_repo_plugins", + "get_online_plugins", + "get_plugins_from_market", + "is_plugin_exists", + "process_plugins_list", +} + + +def _parse(path: Path) -> ast.Module: + """解析 UTF-8 Python 源码,兼容历史插件文件可能携带的 BOM。""" + return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + + +def _module_imports(tree: ast.AST) -> set[str]: + """返回语法树中的绝对导入模块,不把导入符号误当成子模块。""" + imports: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + imports.add(node.module) + return imports + + +def _public_names(tree: ast.AST) -> set[str]: + """返回模块定义或非私有导入到顶层的符号名。""" + names: set[str] = set() + for node in tree.body: + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + names.add(node.name) + elif isinstance(node, ast.ImportFrom): + names.update(alias.asname or alias.name for alias in node.names) + elif isinstance(node, ast.Import): + names.update(alias.asname or alias.name.split(".", 1)[0] for alias in node.names) + elif isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + names.update(target.id for target in targets if isinstance(target, ast.Name)) + return {name for name in names if not name.startswith("_")} + + +def test_official_plugin_legacy_imports_are_exactly_routed_or_retired() -> None: + """官方插件旧导入必须有精确 Compat 路由,或保持一条已审查的可选退役路径。""" + baseline = json.loads(OFFICIAL_PLUGIN_BASELINE.read_text(encoding="utf-8")) + legacy_imports = { + module + for module in baseline["imports"] + if module == "app.log" + or module.startswith(("app.core.", "app.helper.", "app.utils.")) + } + routed = set(MODULE_ALIASES) | set(PACKAGE_ALIASES) + + # V2 AgentResourceOfficer 对该模块使用可选导入;宿主已明确删除,不应为它恢复源码或猜测映射。 + explicitly_retired = {"app.helper.subscribe"} + assert legacy_imports - routed == explicitly_retired + assert explicitly_retired.isdisjoint(routed) + + +def test_plugin_manager_legacy_and_sdk_paths_share_canonical_identity() -> None: + """V2 旧路径与 V3 SDK 必须复用同一个 PluginManager 身份及已验证成员。""" + canonical = importlib.import_module("app.runtime.extensions.plugin_manager") + legacy = importlib.import_module("app.core.plugin") + sdk = importlib.import_module("app.sdk.plugins") + + assert MODULE_ALIASES["app.core.plugin"].target == canonical.__name__ + assert MODULE_ALIASES["app.core.plugin"].replacement == "app.sdk.plugins" + assert legacy is canonical + assert legacy.PluginManager is canonical.PluginManager + assert sdk.PluginManager is canonical.PluginManager + assert AUDITED_PLUGIN_MANAGER_MEMBERS <= set(dir(canonical.PluginManager)) + assert sdk.__all__ == ["ModuleManager", "PluginManager"] + assert not hasattr(sdk, "PluginHelper") + + +def test_plugin_helper_stays_on_exact_legacy_adapter_route() -> None: + """PluginHelper 只保留精确旧 Helper 路由,不进入推荐插件 SDK。""" + canonical = importlib.import_module("app.adapters.external.market") + legacy = importlib.import_module("app.helper.plugin") + alias = MODULE_ALIASES["app.helper.plugin"] + + assert alias.target == canonical.__name__ + assert alias.replacement == canonical.__name__ + assert legacy is canonical + assert legacy.PluginHelper is canonical.PluginHelper + + +def test_plugin_entry_and_application_do_not_reimport_concrete_legacy_owners() -> None: + """Plugin 入口与应用用例不得重新依赖 PluginHelper、ServerHelper 或具体 PluginManager。""" + forbidden_modules = { + "app.adapters.external.market", + "app.adapters.external.server", + "app.runtime.extensions.plugin_manager", + } + forbidden_names = {"MoviePilotServerHelper", "PluginHelper", "PluginManager"} + paths = [APP_ROOT / "api" / "endpoints" / "plugin.py"] + paths.extend((APP_ROOT / "application" / "plugin").rglob("*.py")) + violations: list[str] = [] + + for path in paths: + tree = _parse(path) + relative = path.relative_to(PROJECT_ROOT).as_posix() + violations.extend( + f"{relative}:import:{module}" + for module in sorted(_module_imports(tree) & forbidden_modules) + ) + referenced = { + node.id + for node in ast.walk(tree) + if isinstance(node, ast.Name) and node.id in forbidden_names + } + violations.extend(f"{relative}:name:{name}" for name in sorted(referenced)) + + assert violations == [] + + +def test_plugin_manager_catalog_compat_methods_remain_thin_delegates() -> None: + """PluginManager 的旧市场目录方法只能委托,不得重新吸收应用目录算法。""" + path = APP_ROOT / "runtime" / "extensions" / "plugin_manager.py" + tree = _parse(path) + manager = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "PluginManager" + ) + methods = { + node.name: node + for node in manager.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in DELEGATED_PLUGIN_CATALOG_METHODS + } + + assert set(methods) == DELEGATED_PLUGIN_CATALOG_METHODS + for method_name, method in methods.items(): + statements = [ + statement + for statement in method.body + if not ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str) + ) + ] + assert len(statements) == 1, method_name + assert isinstance(statements[0], ast.Return), method_name + value = statements[0].value + if isinstance(value, ast.Await): + value = value.value + assert isinstance(value, ast.Call), method_name + assert ast.unparse(value.func).startswith("self._plugin_catalog_view."), method_name + + +def test_plugin_capability_package_roots_do_not_duplicate_exports() -> None: + """多文件插件能力包根只保留说明,不得复制子模块实现或宿主导出。""" + violations: list[str] = [] + for package in PLUGIN_COMPONENT_PACKAGES: + path = package / "__init__.py" + tree = _parse(path) + if not ast.get_docstring(tree) or len(tree.body) != 1 or _public_names(tree): + violations.append(path.relative_to(PROJECT_ROOT).as_posix()) + + assert violations == [] + + +def test_new_host_production_modules_use_single_word_filenames() -> None: + """宿主多词生产文件必须等于审查低水位,新增文件只能使用单个小写词。""" + policy = json.loads(FILENAME_POLICY.read_text(encoding="utf-8")) + assert policy["schema_version"] == 1 + assert policy["scope"] == { + "root": "app", + "excluded": ["app/plugins"], + "special": ["__init__.py"], + } + single_word = re.compile(r"[a-z][a-z0-9]*") + actual = sorted( + path.relative_to(PROJECT_ROOT).as_posix() + for path in APP_ROOT.rglob("*.py") + if not path.is_relative_to(APP_ROOT / "plugins") + and path.name != "__init__.py" + and not single_word.fullmatch(path.stem) + ) + + assert actual == policy["grandfathered_multiword_files"] diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index a01934103..45b02c690 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -932,7 +932,7 @@ def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped(): storage = MagicMock() storage.delete.return_value = True - with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage): + with patch("app.runtime.extensions.plugin.storage._plugin_storage", storage): assert manager.delete_plugin_config("DemoPlugin", force=True) is True storage.delete.assert_called_once_with("plugin.DemoPlugin") Singleton._instances.pop((PluginManager, (), frozenset()), None) @@ -998,6 +998,29 @@ def test_uninstall_virtual_instance_never_removes_source_package(monkeypatch): ) plugin_manager.delete_plugin_instance.assert_called_once_with("DemoPluginwork") plugin_manager.remove_plugin.assert_called_once_with("DemoPluginwork") + plugin_manager.remove_plugin_package.assert_not_called() + + +def test_uninstall_clone_delegates_physical_removal_to_package_owner(monkeypatch): + """HTTP 分身卸载不得自行拼接路径或直接删除目录。""" + plugin_manager = MagicMock() + plugin_manager.get_plugin_instance.return_value = None + plugin_manager.get_plugin_source_instances.return_value = [] + plugin_manager.plugins = {"DemoPluginwork": MagicMock(is_clone=True)} + plugin_manager.remove_plugin_package.return_value = True + config = MagicMock() + config.get.return_value = ["DemoPluginwork"] + monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager) + monkeypatch.setattr(plugin_endpoint, "get_configured_system_config", lambda: config) + monkeypatch.setattr(plugin_endpoint, "remove_plugin_api", MagicMock()) + monkeypatch.setattr(plugin_endpoint, "remove_plugin_job", MagicMock()) + monkeypatch.setattr(plugin_endpoint, "remove_plugin_from_folders", MagicMock()) + + result = uninstall_plugin("DemoPluginwork", None) + + assert result.success is True + plugin_manager.remove_plugin_package.assert_called_once_with("DemoPluginwork") + assert "DemoPluginwork" not in plugin_manager.plugins def test_sealed_http_uninstall_rejects_before_first_side_effect(monkeypatch): @@ -1088,7 +1111,7 @@ def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped(): storage = MagicMock() storage.delete_data.side_effect = lambda pid: calls.append(pid) - with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage): + with patch("app.runtime.extensions.plugin.storage._plugin_storage", storage): assert manager.delete_plugin_data("DemoPlugin", force=True) is True assert calls == ["DemoPlugin"] diff --git a/tests/test_plugin_external_install_boundary.py b/tests/test_plugin_external_install_boundary.py index e1023881e..c4c384bd1 100644 --- a/tests/test_plugin_external_install_boundary.py +++ b/tests/test_plugin_external_install_boundary.py @@ -27,9 +27,8 @@ REPO_URL = "https://github.com/example/moviepilot-plugins" def test_package_manager_sync_preserves_external_install_contract() -> None: """同步包适配器必须调用包级入口,不能再次进入公开 Gateway。""" - helper = Mock() - helper._PluginHelper__install_package.return_value = (True, "installed") - manager = PluginPackageManager(helper=helper) + manager = PluginPackageManager(source=Mock()) + manager.install_raw = Mock(return_value=(True, "installed")) result = manager.install( plugin_id="DemoPlugin", @@ -40,7 +39,7 @@ def test_package_manager_sync_preserves_external_install_contract() -> None: ) assert result == (True, "installed") - helper._PluginHelper__install_package.assert_called_once_with( + manager.install_raw.assert_called_once_with( pid="DemoPlugin", repo_url=REPO_URL, package_version="v3", @@ -48,15 +47,13 @@ def test_package_manager_sync_preserves_external_install_contract() -> None: force_install=False, before_dependency_install=None, ) - helper.install.assert_not_called() @pytest.mark.asyncio async def test_package_manager_async_preserves_external_install_contract() -> None: """异步包适配器必须调用包级入口,不能再次进入公开 Gateway。""" - helper = Mock() - helper._PluginHelper__async_install_package = AsyncMock(return_value=(True, "installed")) - manager = PluginPackageManager(helper=helper) + manager = PluginPackageManager(source=Mock()) + manager.async_install_raw = AsyncMock(return_value=(True, "installed")) result = await manager.async_install( plugin_id="DemoPlugin", @@ -67,7 +64,7 @@ async def test_package_manager_async_preserves_external_install_contract() -> No ) assert result == (True, "installed") - helper._PluginHelper__async_install_package.assert_awaited_once_with( + manager.async_install_raw.assert_awaited_once_with( pid="DemoPlugin", repo_url=REPO_URL, package_version="v3", @@ -75,7 +72,6 @@ async def test_package_manager_async_preserves_external_install_contract() -> No force_install=False, before_dependency_install=None, ) - helper.async_install.assert_not_called() @pytest.mark.asyncio @@ -83,7 +79,6 @@ async def test_package_manager_captures_native_state_only_when_dependency_instal monkeypatch, ) -> None: """包适配器只在安装器确认存在依赖清单后记录原生载荷。""" - helper = Mock() checkpoint = SimpleNamespace(native_dependencies=None) baseline = object() capture = Mock(return_value=baseline) @@ -92,14 +87,12 @@ async def test_package_manager_captures_native_state_only_when_dependency_instal kwargs["before_dependency_install"]() return True, "installed" - helper._PluginHelper__async_install_package = AsyncMock( - side_effect=install_with_dependency - ) monkeypatch.setattr( "app.adapters.system.plugin.package.capture_loaded_native_dependencies", capture, ) - manager = PluginPackageManager(helper=helper) + manager = PluginPackageManager(source=Mock()) + manager.async_install_raw = AsyncMock(side_effect=install_with_dependency) result = await manager.async_install( plugin_id="DemoPlugin", @@ -136,21 +129,20 @@ async def test_dependency_manifest_is_observed_before_install( plugin_dir.mkdir(parents=True) manifest = plugin_dir / filename manifest.write_text(content, encoding="utf-8") - helper = PluginHelper() + manager = PluginPackageManager(plugin_root=plugin_root) calls = [] async def install(path, _find_links=None): calls.append(("install", path)) return True, "" - monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root) monkeypatch.setattr( - helper, - "_PluginHelper__async_install_packages_with_fallback", + manager._health, + "async_install_packages_with_fallback", install, ) - result = await helper._PluginHelper__async_install_dependencies_if_required( + result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", lambda: calls.append(("observe", None)), ) @@ -170,26 +162,25 @@ async def test_dependency_observer_failure_does_not_block_install( plugin_dir.mkdir(parents=True) requirements_file = plugin_dir / "requirements.txt" requirements_file.write_text("demo>=1\n", encoding="utf-8") - helper = PluginHelper() + manager = PluginPackageManager(plugin_root=plugin_root) install = AsyncMock(return_value=(True, "")) def failing_observer(): raise OSError("probe unavailable") - monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root) monkeypatch.setattr( - helper, - "_PluginHelper__async_install_packages_with_fallback", + manager._health, + "async_install_packages_with_fallback", install, ) - result = await helper._PluginHelper__async_install_dependencies_if_required( + result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", failing_observer, ) assert result == (True, True, "") - install.assert_awaited_once_with(requirements_file) + install.assert_awaited_once_with(requirements_file, None) @pytest.mark.asyncio @@ -200,11 +191,9 @@ async def test_dependency_observer_is_not_called_without_manifest( """未声明依赖的插件不承担原生环境快照成本。""" plugin_root = tmp_path / "plugins" (plugin_root / "demoplugin").mkdir(parents=True) - helper = PluginHelper() + manager = PluginPackageManager(plugin_root=plugin_root) observer = Mock() - monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root) - - result = await helper._PluginHelper__async_install_dependencies_if_required( + result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", observer, ) @@ -237,24 +226,23 @@ async def test_empty_dependency_manifest_does_not_trigger_native_snapshot( plugin_dir.mkdir(parents=True) manifest = plugin_dir / filename manifest.write_text(content, encoding="utf-8") - helper = PluginHelper() + manager = PluginPackageManager(plugin_root=plugin_root) observer = Mock() install = AsyncMock(return_value=(True, "")) - monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root) monkeypatch.setattr( - helper, - "_PluginHelper__async_install_packages_with_fallback", + manager._health, + "async_install_packages_with_fallback", install, ) - result = await helper._PluginHelper__async_install_dependencies_if_required( + result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", observer, ) assert result == (True, True, "") observer.assert_not_called() - install.assert_awaited_once_with(manifest) + install.assert_awaited_once_with(manifest, None) @pytest.mark.asyncio @@ -271,21 +259,20 @@ async def test_legacy_unparsed_requirement_still_triggers_native_snapshot( "git+https://example.invalid/demo.git@fixed\n", encoding="utf-8", ) - helper = PluginHelper() + manager = PluginPackageManager(plugin_root=plugin_root) calls = [] async def install(path, _find_links=None): calls.append(("install", path)) return True, "" - monkeypatch.setattr(market, "PLUGIN_DIR", plugin_root) monkeypatch.setattr( - helper, - "_PluginHelper__async_install_packages_with_fallback", + manager._health, + "async_install_packages_with_fallback", install, ) - result = await helper._PluginHelper__async_install_dependencies_if_required( + result = await manager._PluginPackageManager__async_install_dependencies_if_required( "DemoPlugin", lambda: calls.append(("observe", None)), ) @@ -723,24 +710,39 @@ async def test_agent_install_uses_application_gateway( def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> None: - """启动组合根必须向 Application 与公开 Helper 发布同一 Gateway。""" - helper = Mock() + """启动组合根必须装配独立 owner,并向公开 Helper 发布同一 Gateway。""" + transport = Mock() + source = Mock() + package_manager = Mock() gateway_calls = [] application_calls = [] + transport_consumers = [] gateway = Mock() sync_runner = Mock(return_value=(True, "installed")) async_runner = AsyncMock(return_value=(True, "installed")) - monkeypatch.setattr(plugins_initializer, "PluginHelper", lambda: helper) + monkeypatch.setattr( + plugins_initializer, + "PluginMarketTransport", + lambda: transport, + ) monkeypatch.setattr( plugins_initializer, "PluginMarketClient", - lambda _helper: Mock(), + lambda injected: transport_consumers.append(("market", injected)) or Mock(), + ) + monkeypatch.setattr( + plugins_initializer, + "PluginPackageSourceClient", + lambda injected: transport_consumers.append(("package", injected)) or source, ) monkeypatch.setattr( plugins_initializer, "PluginPackageManager", - lambda _helper: Mock(), + lambda **kwargs: ( + transport_consumers.append(("package-owner", kwargs["source"])) + or package_manager + ), ) monkeypatch.setattr( plugins_initializer, @@ -805,6 +807,7 @@ def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> "configure_site_auth_level_provider", "configure_installed_plugins_provider", "configure_plugin_catalog_factory", + "configure_plugin_runtime_factory", "configure_plugin_route_refresher", "configure_plugin_system", "configure_plugin_storage", @@ -824,6 +827,11 @@ def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> plugins_initializer.configure_plugin_services() + assert transport_consumers == [ + ("market", transport), + ("package", transport), + ("package-owner", source), + ] assert application_calls == [gateway] assert len(gateway_calls) == 1 assert callable(gateway_calls[0]["install"]) diff --git a/tests/test_plugin_folders.py b/tests/test_plugin_folders.py index 41cef4c5f..8adc88f5a 100644 --- a/tests/test_plugin_folders.py +++ b/tests/test_plugin_folders.py @@ -1,11 +1,14 @@ import asyncio -from contextlib import nullcontext +from contextlib import contextmanager, nullcontext from unittest.mock import AsyncMock, MagicMock import pytest from app.application.plugin import folders -from app.schemas.exception import PersistenceUnavailableError +from app.schemas.exception import ( + PersistenceUnavailableError, + PluginMutationRejectedError, +) from app.schemas.types import SystemConfigKey @@ -93,3 +96,171 @@ def test_save_folder_propagates_persistence_unavailable(): with pytest.raises(PersistenceUnavailableError): asyncio.run(service.save({"常用": []})) + + +def test_folder_mutations_write_isolated_snapshots(): + """创建、更新和删除必须按顺序写入副本,不能修改配置读取值。""" + stored = {"常用": ["DemoPlugin"]} + writes: list[dict] = [] + + async def write(value): + """记录每次 Application 文件夹写入。""" + writes.append(value) + + service = folders.PluginFolderService( + read=lambda: stored if not writes else writes[-1], + write=write, + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + ) + + created = asyncio.run(service.create("稍后")) + updated = asyncio.run(service.update_plugins("稍后", ["OtherPlugin"])) + deleted = asyncio.run(service.delete("常用")) + + assert created == folders.PluginFolderResult(True, "文件夹 '稍后' 创建成功") + assert updated == folders.PluginFolderResult( + True, + "文件夹 '稍后' 中的插件已更新", + ) + assert deleted == folders.PluginFolderResult(True, "文件夹 '常用' 删除成功") + assert writes == [ + {"常用": ["DemoPlugin"], "稍后": []}, + {"常用": ["DemoPlugin"], "稍后": ["OtherPlugin"]}, + {"稍后": ["OtherPlugin"]}, + ] + assert stored == {"常用": ["DemoPlugin"]} + + +def test_folder_mutations_report_duplicate_and_missing_names(): + """重复创建和删除不存在目录都应返回稳定业务结果且不写配置。""" + write = AsyncMock() + service = folders.PluginFolderService( + read=lambda: {"常用": []}, + write=write, + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + ) + + duplicate = asyncio.run(service.create("常用")) + missing = asyncio.run(service.delete("不存在")) + + assert duplicate == folders.PluginFolderResult(False, "文件夹 '常用' 已存在") + assert missing == folders.PluginFolderResult(False, "文件夹 '不存在' 不存在") + write.assert_not_awaited() + + +def test_folder_mutation_rejection_is_returned_without_accessing_storage(): + """运行时封口时应直接返回拒绝结果,不得继续访问配置存储。""" + read = MagicMock() + + @contextmanager + def reject(operation): + """模拟插件运行时拒绝新的可变事务。""" + raise PluginMutationRejectedError(operation) + yield + + service = folders.PluginFolderService( + read=read, + write=AsyncMock(), + write_sync=MagicMock(), + mutation=reject, + ) + + result = asyncio.run(service.update_plugins("常用", ["DemoPlugin"])) + + assert result.success is False + assert result.message == "插件运行时已进入停机阶段,拒绝更新插件文件夹 常用" + read.assert_not_called() + + +def test_all_folder_write_commands_map_runtime_sealing_to_results(): + """保存、创建和删除入口都应把运行时封口映射为稳定失败结果。""" + @contextmanager + def reject(operation): + """模拟每个写命令在进入配置存储前被运行时拒绝。""" + raise PluginMutationRejectedError(operation) + yield + + service = folders.PluginFolderService( + read=MagicMock(), + write=AsyncMock(), + write_sync=MagicMock(), + mutation=reject, + ) + + saved = asyncio.run(service.save({"常用": []})) + created = asyncio.run(service.create("常用")) + deleted = asyncio.run(service.delete("常用")) + + assert saved.message.endswith("拒绝保存插件文件夹配置") + assert created.message.endswith("拒绝创建插件文件夹 常用") + assert deleted.message.endswith("拒绝删除插件文件夹 常用") + assert saved.success is created.success is deleted.success is False + + +def test_add_clone_inherits_first_current_or_legacy_folder(): + """分身只继承原插件命中的首个目录,并兼容字典和旧列表形态。""" + for stored in ( + {"常用": {"plugins": ["DemoPlugin"], "order": 1}}, + {"旧目录": ["DemoPlugin"]}, + ): + state = {"folders": stored} + + def persist(value): + """模拟同步配置存储把写入值发布为下一次读取快照。""" + state["folders"] = value + + write_sync = MagicMock(side_effect=persist) + service = folders.PluginFolderService( + read=lambda state=state: state["folders"], + write=AsyncMock(), + write_sync=write_sync, + mutation=lambda _operation: nullcontext(), + ) + + service.add_clone("DemoPlugin", "DemoPlugin__copy") + service.add_clone("DemoPlugin", "DemoPlugin__copy") + + written = write_sync.call_args.args[0] + plugins = folders._folder_plugins(next(iter(written.values()))) + assert plugins == ["DemoPlugin", "DemoPlugin__copy"] + write_sync.assert_called_once() + + +def test_add_clone_and_remove_plugin_ignore_invalid_folder_shapes(): + """损坏的目录条目不得被当作插件列表,也不能触发无意义写入。""" + write_sync = MagicMock() + service = folders.PluginFolderService( + read=lambda: {"损坏": {"plugins": "DemoPlugin"}}, + write=AsyncMock(), + write_sync=write_sync, + mutation=lambda _operation: nullcontext(), + ) + + service.add_clone("DemoPlugin", "DemoPlugin__copy") + service.remove_plugin("DemoPlugin") + + write_sync.assert_not_called() + + +def test_add_clone_failure_and_public_entry_do_not_escape(monkeypatch): + """分身目录处理失败只记录诊断,公开入口仍委托唯一服务实例。""" + error = MagicMock() + failing = folders.PluginFolderService( + read=MagicMock(side_effect=RuntimeError("broken folders")), + write=AsyncMock(), + write_sync=MagicMock(), + mutation=lambda _operation: nullcontext(), + ) + monkeypatch.setattr(folders.logger, "error", error) + + failing.add_clone("DemoPlugin", "DemoPlugin__copy") + + error.assert_called_once() + service = MagicMock() + monkeypatch.setattr(folders, "get_plugin_folder_service", lambda: service) + + folders.add_clone_to_plugin_folder("DemoPlugin", "DemoPlugin__copy") + + service.add_clone.assert_called_once_with("DemoPlugin", "DemoPlugin__copy") diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index b8265dece..416dcc9cb 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -15,10 +15,28 @@ import pytest from packaging.requirements import Requirement from packaging.version import Version +from app.adapters.external.plugin import client as plugin_client_module +from app.adapters.external.plugin.client import ( + PluginMarketTransport, + PluginPackageSourceClient, +) +from app.adapters.system.plugin.health import PluginRuntimeHealth +from app.adapters.system.plugin.package import PluginPackageManager + PLUGIN_ID = "DemoPlugin" REPO_URL = "https://github.com/demo/MoviePilot-Plugins" +def _package_owner( + transport, *, plugin_root: Path | None = None +) -> PluginPackageManager: + """按生产组合方式为安装测试构造系统包 owner。""" + return PluginPackageManager( + source=PluginPackageSourceClient(transport), + plugin_root=plugin_root, + ) + + def _patch_catalog_settings(monkeypatch, **values) -> None: """通过只读端口注入插件目录测试需要的部署配置。""" from app.runtime.extensions.plugin import catalog as catalog_module @@ -34,12 +52,13 @@ def _patch_catalog_settings(monkeypatch, **values) -> None: @pytest.fixture(autouse=True) def _configure_plugin_catalog_factory(monkeypatch): """为直接构造 PluginManager 的测试注入真实目录用例和假持久化接缝。""" - from app.adapters.external import market as market_module from app.adapters.external.plugin.client import PluginMarketClient from app.application.plugin.catalog import PluginCatalogService from app.foundation.version import compare_version from app.runtime.extensions import plugin_manager as manager_module - original_runtime_setting = market_module.get_runtime_setting + from app.runtime.extensions.plugin.storage import get_plugin_storage + from app.schemas.types import SystemConfigKey + original_runtime_setting = plugin_client_module.get_runtime_setting class _SettingsStub(SimpleNamespace): """允许存量用例覆盖尚未显式声明的配置键。""" @@ -58,27 +77,26 @@ def _configure_plugin_catalog_factory(monkeypatch): REPO_GITHUB_HEADERS=original_runtime_setting("REPO_GITHUB_HEADERS"), PLUGIN_LOCAL_REPO_PATHS="", ) - monkeypatch.setattr(market_module, "settings", market_settings, raising=False) monkeypatch.setattr( - market_module, + plugin_client_module, "get_runtime_setting", lambda key, default=None: ( - getattr(market_module.settings, key) - if hasattr(market_module.settings, key) + getattr(market_settings, key) + if hasattr(market_settings, key) else original_runtime_setting(key, default) ), ) - def build_catalog(manager): + def build_catalog(plugin_mapper): """按生产组合方式连接目录服务,但保留测试可替换的依赖。""" client = PluginMarketClient() return PluginCatalogService( market_loader=client.get_plugins, async_market_loader=client.async_get_plugins, - installed_plugins_provider=lambda: manager_module.get_plugin_storage().read( - manager_module.SystemConfigKey.UserInstalledPlugins + installed_plugins_provider=lambda: get_plugin_storage().read( + SystemConfigKey.UserInstalledPlugins ) or [], - plugin_mapper=manager._process_plugin_info, + plugin_mapper=plugin_mapper, is_local_repo=PluginMarketClient.is_local_repo_url, version_compare=compare_version, warning=manager_module.logger.warning, @@ -155,10 +173,14 @@ def _create_fake_uv(root: Path) -> Path: def _patch_release_install_settings(monkeypatch, tmp_path: Path) -> None: """隔离 release 安装根目录,并阻止测试误触真实根路径。""" - monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace( + values = SimpleNamespace( ROOT_PATH=tmp_path, REPO_GITHUB_HEADERS=lambda repo=None: {}, - )) + ) + monkeypatch.setattr( + "app.adapters.system.plugin.package.get_runtime_setting", + lambda key: getattr(values, key), + ) original_mkdir = Path.mkdir safe_root = tmp_path.resolve() @@ -179,10 +201,10 @@ def _patch_sync_remote_install(helper, monkeypatch, meta: dict, """隔离同步远端插件安装流程,只观察 release 与文件列表准备路径选择。""" calls = [] monkeypatch.setattr(helper, "get_plugin_package_version", lambda *_args: "v2") - monkeypatch.setattr(helper, "_PluginHelper__get_plugin_meta", lambda *_args: meta) - monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None) - monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove")) - monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, "")) + monkeypatch.setattr(helper, "_PluginPackageManager__get_plugin_meta", lambda *_args: meta) + monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: None) + monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: calls.append("remove")) + monkeypatch.setattr(helper, "_PluginPackageManager__install_dependencies_if_required", lambda _pid: (False, True, "")) def fake_release(_pid, _user_repo, _release_tag): calls.append("release") @@ -192,8 +214,8 @@ def _patch_sync_remote_install(helper, monkeypatch, meta: dict, calls.append("filelist") return filelist_result - monkeypatch.setattr(helper, "_PluginHelper__install_from_release", fake_release) - monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_sync", fake_filelist) + monkeypatch.setattr(helper, "_PluginPackageManager__install_from_release", fake_release) + monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_sync", fake_filelist) return calls @@ -231,14 +253,14 @@ def _patch_async_remote_install(helper, monkeypatch, meta: dict, return None monkeypatch.setattr(helper, "async_get_plugin_package_version", fake_package_version) - monkeypatch.setattr(helper, "_PluginHelper__async_get_plugin_meta", fake_meta) - monkeypatch.setattr(helper, "_PluginHelper__async_backup_plugin", fake_backup) - monkeypatch.setattr(helper, "_PluginHelper__async_remove_old_plugin", fake_remove) - monkeypatch.setattr(helper, "_PluginHelper__async_install_dependencies_if_required", fake_dependencies) - monkeypatch.setattr(helper, "_PluginHelper__async_install_from_release", fake_release) - monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist) + monkeypatch.setattr(helper, "_PluginPackageManager__async_get_plugin_meta", fake_meta) + monkeypatch.setattr(helper, "_PluginPackageManager__async_backup_plugin", fake_backup) + monkeypatch.setattr(helper, "_PluginPackageManager__async_remove_old_plugin", fake_remove) + monkeypatch.setattr(helper, "_PluginPackageManager__async_install_dependencies_if_required", fake_dependencies) + monkeypatch.setattr(helper, "_PluginPackageManager__async_install_from_release", fake_release) + monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_async", fake_filelist) monkeypatch.setattr( - "app.adapters.external.market._await_thread_operation", + "app.adapters.system.plugin.package._await_thread_operation", fake_to_thread, ) return calls @@ -273,17 +295,22 @@ class TestPluginHelper: 插件库强制刷新时远端索引 URL 也要变化,避免命中镜像或代理缓存。 """ try: - from app.adapters.external.market import PluginHelper from app.runtime.cache import fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") url = "https://raw.githubusercontent.com/user/repo/main/package.json" - assert url == PluginHelper._PluginHelper__append_cache_buster(url) - with patch("app.adapters.external.market.time.time_ns", return_value=1234567890): + append_cache_buster = ( + PluginMarketTransport._PluginMarketTransport__append_cache_buster + ) + assert url == append_cache_buster(url) + with patch( + "app.adapters.external.plugin.client.time.time_ns", + return_value=1234567890, + ): with fresh(True): - refreshed_url = PluginHelper._PluginHelper__append_cache_buster(url) + refreshed_url = append_cache_buster(url) assert "https://raw.githubusercontent.com/user/repo/main/package.json?_refresh=1234567890" == refreshed_url @@ -296,7 +323,9 @@ class TestPluginHelper: except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - success, message = PluginHelper.check_plugin_system_version({"version": "1.0.0"}) + success, message = PluginHelper.check_plugin_system_version( + {"version": "1.0.0"} + ) assert success assert "" == message @@ -305,13 +334,16 @@ class TestPluginHelper: """ 插件声明的主系统版本范围不满足当前版本时拒绝安装。 """ - try: - from app.adapters.external.market import PluginHelper - except ModuleNotFoundError as exc: - pytest.skip(f"missing dependency: {exc}") + from app.adapters.external.market import PluginHelper - with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")): - success, message = PluginHelper.check_plugin_system_version({"system_version": ">=2.13.0"}) + with patch.object( + PluginMarketTransport, + "get_current_system_version", + return_value=Version("2.12.2"), + ): + success, message = PluginHelper.check_plugin_system_version( + {"system_version": ">=2.13.0"} + ) assert not success assert "MoviePilot 版本 >=2.13.0" in message @@ -320,13 +352,16 @@ class TestPluginHelper: """ 兼容带 v 前缀的版本范围,降低插件索引维护成本。 """ - try: - from app.adapters.external.market import PluginHelper - except ModuleNotFoundError as exc: - pytest.skip(f"missing dependency: {exc}") + from app.adapters.external.market import PluginHelper - with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")): - success, message = PluginHelper.check_plugin_system_version({"system_version": ">=v2.12.0"}) + with patch.object( + PluginMarketTransport, + "get_current_system_version", + return_value=Version("2.12.2"), + ): + success, message = PluginHelper.check_plugin_system_version( + {"system_version": ">=v2.12.0"} + ) assert success assert "" == message @@ -335,11 +370,6 @@ class TestPluginHelper: """ release 版本列表只暴露符合插件 tag 规范且存在同名 zip 资产的版本。 """ - try: - from app.adapters.external.market import PluginHelper - except ModuleNotFoundError as exc: - pytest.skip(f"missing dependency: {exc}") - payload = [ { "tag_name": "DemoPlugin_v1.2.3", @@ -359,14 +389,14 @@ class TestPluginHelper: "assets": [{"name": "otherplugin_v9.9.9.zip", "id": 3}], }, ] - helper = PluginHelper() + transport = PluginMarketTransport() monkeypatch.setattr( - helper, - "_PluginHelper__request_with_fallback", + transport, + "_PluginMarketTransport__request_with_fallback", lambda *_args, **_kwargs: _FakeTextResponse(200, payload), ) - releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL) + releases = transport.get_plugin_release_versions(PLUGIN_ID, REPO_URL) assert releases == [ { @@ -384,7 +414,7 @@ class TestPluginHelper: 插件市场强制刷新时 Release 列表请求也要绕过 GitHub 镜像或代理缓存。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport from app.runtime.cache import fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -395,13 +425,20 @@ class TestPluginHelper: requested_urls.append(url) return _FakeTextResponse(200, []) - helper = PluginHelper() - helper.get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + transport = PluginMarketTransport() + transport._get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__request_with_fallback", + fake_request, + ) - with patch("app.adapters.external.market.time.time_ns", return_value=1234567890): + with patch( + "app.adapters.external.plugin.client.time.time_ns", + return_value=1234567890, + ): with fresh(True): - helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL) + transport.get_plugin_release_versions(PLUGIN_ID, REPO_URL) assert requested_urls == [ "https://api.github.com/repos/demo/MoviePilot-Plugins/releases?per_page=100&page=1&_refresh=1234567890" @@ -412,7 +449,7 @@ class TestPluginHelper: 多插件共用 Release 列表时需要分页,避免目标插件历史发行版被第一页之外的数据遮蔽。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -433,11 +470,15 @@ class TestPluginHelper: requested_pages.append(page) return _FakeTextResponse(200, payload_by_page[page]) - helper = PluginHelper() - helper.get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + transport = PluginMarketTransport() + transport._get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__request_with_fallback", + fake_request, + ) - releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL) + releases = transport.get_plugin_release_versions(PLUGIN_ID, REPO_URL) assert requested_pages == ["1", "2"] assert [item["version"] for item in releases] == ["1.2.0"] @@ -447,7 +488,7 @@ class TestPluginHelper: 同一仓库的不同插件共享 GitHub Release 分页结果,避免按插件 ID 重复请求。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -468,12 +509,16 @@ class TestPluginHelper: request_count += 1 return _FakeTextResponse(200, payload) - helper = PluginHelper() - helper.get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + transport = PluginMarketTransport() + transport._get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__request_with_fallback", + fake_request, + ) - demo_releases = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) - other_releases = helper.get_plugin_release_versions("OtherPlugin", REPO_URL) + demo_releases = transport.get_plugin_release_versions("DemoPlugin", REPO_URL) + other_releases = transport.get_plugin_release_versions("OtherPlugin", REPO_URL) assert request_count == 1 assert [item["version"] for item in demo_releases] == ["1.2.3"] @@ -484,7 +529,7 @@ class TestPluginHelper: 同一仓库的并发强制刷新共享一个请求任务,避免缓存失效瞬间放大 GitHub 请求。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport from app.runtime.cache import async_fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -508,13 +553,17 @@ class TestPluginHelper: return _FakeTextResponse(200, payload) async def run_test(): - helper = PluginHelper() - await helper.async_get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + transport = PluginMarketTransport() + await transport._async_get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__async_request_with_fallback", + fake_request, + ) async with async_fresh(True): return await asyncio.gather( - helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL), - helper.async_get_plugin_release_versions("OtherPlugin", REPO_URL), + transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL), + transport.async_get_plugin_release_versions("OtherPlugin", REPO_URL), ) demo_releases, other_releases = asyncio.run(run_test()) @@ -526,7 +575,7 @@ class TestPluginHelper: def test_async_forced_release_refresh_does_not_reuse_normal_read_task(self, monkeypatch): """强刷等待在途普通读取后再请求,最终缓存必须保留强刷结果。""" try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport from app.runtime.cache import async_fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -553,22 +602,26 @@ class TestPluginHelper: return _FakeTextResponse(200, fresh_payload) async def run_test(): - helper = PluginHelper() - await helper.async_get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + transport = PluginMarketTransport() + await transport._async_get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__async_request_with_fallback", + fake_request, + ) normal_task = asyncio.create_task( - helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) ) await first_request_started.wait() async with async_fresh(True): force_task = asyncio.create_task( - helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) ) await asyncio.sleep(0.01) request_count_before_normal_finished = request_count release_first_request.set() normal_result, force_result = await asyncio.gather(normal_task, force_task) - cached_result = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + cached_result = await transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) return request_count_before_normal_finished, normal_result, force_result, cached_result request_count_before_normal_finished, normal_result, force_result, cached_result = asyncio.run(run_test()) @@ -582,15 +635,14 @@ class TestPluginHelper: def test_async_release_read_follows_host_task_shutdown(self, monkeypatch): """被 shield 的仓库级请求仍须登记 owner,并随宿主关停取消。""" try: - from app.adapters.external import market as market_module - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport from app.runtime.tasks import TaskRegistry except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") async def run_test(): """阻塞仓库读取后关闭登记器,返回 owner 与取消收敛状态。""" - helper = PluginHelper() + transport = PluginMarketTransport() registry = TaskRegistry() started = asyncio.Event() cancelled = asyncio.Event() @@ -604,26 +656,26 @@ class TestPluginHelper: cancelled.set() raise - await helper.async_get_plugin_release_versions.cache_clear() + await transport._async_get_plugin_repo_releases.cache_clear() monkeypatch.setattr( - helper, - "_PluginHelper__async_request_with_fallback", + transport, + "_PluginMarketTransport__async_request_with_fallback", fake_request, ) monkeypatch.setattr( - market_module, + plugin_client_module, "get_task_registry", lambda: registry, ) caller = asyncio.create_task( - helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) ) await started.wait() owners = tuple(record.owner for record in registry.records) converged = await registry.shutdown(timeout_seconds=1.0) results = await asyncio.gather(caller, return_exceptions=True) await asyncio.sleep(0) - return owners, converged, cancelled.is_set(), results, helper._release_tasks + return owners, converged, cancelled.is_set(), results, transport._release_tasks owners, converged, cancelled, results, release_tasks = asyncio.run(run_test()) @@ -636,7 +688,7 @@ class TestPluginHelper: def test_async_normal_release_read_does_not_wait_for_pending_force_refresh(self, monkeypatch): """普通读取遇到后台强刷时仍优先返回已有缓存,避免页面响应被强刷阻塞。""" try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport from app.runtime.cache import async_fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -663,23 +715,27 @@ class TestPluginHelper: return _FakeTextResponse(200, fresh_payload) async def run_test(): - helper = PluginHelper() - await helper.async_get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) - initial = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + transport = PluginMarketTransport() + await transport._async_get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__async_request_with_fallback", + fake_request, + ) + initial = await transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) async with async_fresh(True): force_task = asyncio.create_task( - helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) ) await force_request_started.wait() normal_task = asyncio.create_task( - helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) ) normal_before_force_finished = await asyncio.wait_for(normal_task, timeout=1) force_done_before_normal_finished = force_task.done() release_force_request.set() force_result = await force_task - cached_result = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + cached_result = await transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) return ( initial, force_done_before_normal_finished, @@ -706,7 +762,7 @@ class TestPluginHelper: def test_async_has_plugin_release_cache_reflects_repository_cache(self, monkeypatch): """Release 缓存探针只判断仓库级缓存是否已经存在,不触发网络请求。""" try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -722,12 +778,16 @@ class TestPluginHelper: return _FakeTextResponse(200, payload) async def run_test(): - helper = PluginHelper() - await helper.async_get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) - before = await helper.async_has_plugin_release_cache(REPO_URL) - await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL) - after = await helper.async_has_plugin_release_cache(REPO_URL) + transport = PluginMarketTransport() + await transport._async_get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__async_request_with_fallback", + fake_request, + ) + before = await transport.async_has_plugin_release_cache(REPO_URL) + await transport.async_get_plugin_release_versions("DemoPlugin", REPO_URL) + after = await transport.async_has_plugin_release_cache(REPO_URL) return before, after before, after = asyncio.run(run_test()) @@ -739,7 +799,7 @@ class TestPluginHelper: def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch): """GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。""" try: - from app.adapters.external.market import PluginHelper + from app.adapters.external.plugin.client import PluginMarketTransport from app.runtime.cache import fresh except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -753,14 +813,18 @@ class TestPluginHelper: def fake_request(*_args, **_kwargs): return responses.pop(0) - helper = PluginHelper() - helper.get_plugin_release_versions.cache_clear() - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request) + transport = PluginMarketTransport() + transport._get_plugin_repo_releases.cache_clear() + monkeypatch.setattr( + transport, + "_PluginMarketTransport__request_with_fallback", + fake_request, + ) - initial = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) + initial = transport.get_plugin_release_versions("DemoPlugin", REPO_URL) with fresh(True): - failed_refresh = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) - cached = helper.get_plugin_release_versions("DemoPlugin", REPO_URL) + failed_refresh = transport.get_plugin_release_versions("DemoPlugin", REPO_URL) + cached = transport.get_plugin_release_versions("DemoPlugin", REPO_URL) assert [item["version"] for item in initial] == ["1.2.3"] assert failed_refresh == [] @@ -772,7 +836,6 @@ class TestPluginHelper: 插件市场 labels 为列表时应转换为字符串,避免响应模型序列化异常。 """ try: - from app.adapters.external.market import PluginHelper from app.runtime.extensions.plugin_manager import PluginManager except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -787,18 +850,22 @@ class TestPluginHelper: } } plugin_manager = PluginManager() - monkeypatch.setattr(plugin_manager, "_plugins", {}) - monkeypatch.setattr(plugin_manager, "_running_plugins", {}) + plugin_manager.plugins.clear() + plugin_manager.running_plugins.clear() _patch_catalog_settings(monkeypatch, VERSION_FLAG="v2") monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.get_plugin_storage", - lambda: SimpleNamespace(read=lambda _key: []), + "app.runtime.extensions.plugin.storage._plugin_storage", + SimpleNamespace(read=lambda _key: []), ) monkeypatch.setattr( "app.runtime.extensions.plugin_manager._site_auth_level_provider", lambda: 1, ) - monkeypatch.setattr(PluginHelper, "get_plugins", lambda _self, *_args: market_plugins) + monkeypatch.setattr( + PluginMarketTransport, + "get_plugins", + lambda _self, *_args: market_plugins, + ) plugins = plugin_manager.get_plugins_from_market(REPO_URL) @@ -812,7 +879,6 @@ class TestPluginHelper: package.v2.json 中的 v2 原生插件,并过滤掉未声明任何版本兼容的 v1 插件。 """ try: - from app.adapters.external.market import PluginHelper from app.runtime.extensions.plugin_manager import PluginManager except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -847,23 +913,27 @@ class TestPluginHelper: return base_plugins plugin_manager = PluginManager() - monkeypatch.setattr(plugin_manager, "_plugins", {}) - monkeypatch.setattr(plugin_manager, "_running_plugins", {}) + plugin_manager.plugins.clear() + plugin_manager.running_plugins.clear() _patch_catalog_settings( monkeypatch, VERSION_FLAG="v3", PLUGIN_MARKET=REPO_URL, ) - monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace(VERSION_FLAG="v3")) monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.get_plugin_storage", - lambda: SimpleNamespace(read=lambda _key: []), + plugin_client_module, + "get_runtime_setting", + lambda key, default=None: "v3" if key == "VERSION_FLAG" else default, + ) + monkeypatch.setattr( + "app.runtime.extensions.plugin.storage._plugin_storage", + SimpleNamespace(read=lambda _key: []), ) monkeypatch.setattr( "app.runtime.extensions.plugin_manager._site_auth_level_provider", lambda: 1, ) - monkeypatch.setattr(PluginHelper, "get_plugins", fake_get_plugins) + monkeypatch.setattr(PluginMarketTransport, "get_plugins", fake_get_plugins) plugins = plugin_manager.get_online_plugins(force=False) plugin_ids = {p.id for p in plugins} @@ -878,11 +948,6 @@ class TestPluginHelper: """ V3 安装链路应能解析 v2 兼容插件:package.v2.json 命中返回 v2,package.json 声明 v2 返回基础版本。 """ - try: - from app.adapters.external.market import PluginHelper - except ModuleNotFoundError as exc: - pytest.skip(f"missing dependency: {exc}") - base_plugins = { "V2FlagPlugin": {"name": "V2Flag", "version": "1.0.0", "v2": True}, "RejectedSharedPlugin": { @@ -902,63 +967,59 @@ class TestPluginHelper: }, } - def fake_get_plugins(_self, _repo_url, package_version=None): + def fake_get_plugins(_repo_url, package_version=None): if package_version == "v3": return {} if package_version == "v2": return v2_native_plugins return base_plugins - monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace(VERSION_FLAG="v3")) - helper = PluginHelper.__new__(PluginHelper) - monkeypatch.setattr(PluginHelper, "get_plugins", fake_get_plugins) + monkeypatch.setattr( + plugin_client_module, + "get_runtime_setting", + lambda key, default=None: "v3" if key == "VERSION_FLAG" else default, + ) + transport = PluginMarketTransport() + monkeypatch.setattr(transport, "get_plugins", fake_get_plugins) - assert helper.get_plugin_package_version("V2NativePlugin", REPO_URL) == "v2" - assert helper.get_plugin_package_version("V2FlagPlugin", REPO_URL) == "" - assert helper.get_plugin_package_version("RejectedV2Plugin", REPO_URL) is None - assert helper.get_plugin_package_version("RejectedSharedPlugin", REPO_URL) is None - assert helper.get_plugin_package_version("LegacyPlugin", REPO_URL) is None + assert transport.get_plugin_package_version("V2NativePlugin", REPO_URL) == "v2" + assert transport.get_plugin_package_version("V2FlagPlugin", REPO_URL) == "" + assert transport.get_plugin_package_version("RejectedV2Plugin", REPO_URL) is None + assert transport.get_plugin_package_version("RejectedSharedPlugin", REPO_URL) is None + assert transport.get_plugin_package_version("LegacyPlugin", REPO_URL) is None def test_explicit_v2_resolution_still_respects_v3_false(self, monkeypatch) -> None: """V3 显式解析 V2 索引时也不得绕过专用副本的排除标志。""" - try: - from app.adapters.external.market import PluginHelper - except ModuleNotFoundError as exc: - pytest.skip(f"missing dependency: {exc}") - monkeypatch.setattr( - "app.adapters.external.market.settings", - SimpleNamespace(VERSION_FLAG="v3"), + plugin_client_module, + "get_runtime_setting", + lambda key, default=None: "v3" if key == "VERSION_FLAG" else default, ) - helper = PluginHelper.__new__(PluginHelper) + transport = PluginMarketTransport() monkeypatch.setattr( - PluginHelper, + transport, "get_plugins", - lambda _self, _repo, package_version=None: { + lambda _repo, package_version=None: { "DefaultV2": {"version": "1.0.0"}, "V3Copied": {"version": "1.0.0", "v3": False}, } if package_version == "v2" else {}, ) - assert helper.get_plugin_package_version( + assert transport.get_plugin_package_version( "DefaultV2", REPO_URL, package_version="v2" ) == "v2" - assert helper.get_plugin_package_version( + assert transport.get_plugin_package_version( "V3Copied", REPO_URL, package_version="v2" ) is None def test_async_resolution_matches_v2_default_compatibility(self, monkeypatch) -> None: """异步安装解析应默认接纳 V2,并排除显式 v3:false 的旧实现。""" - try: - from app.adapters.external.market import PluginHelper - except ModuleNotFoundError as exc: - pytest.skip(f"missing dependency: {exc}") - monkeypatch.setattr( - "app.adapters.external.market.settings", - SimpleNamespace(VERSION_FLAG="v3"), + plugin_client_module, + "get_runtime_setting", + lambda key, default=None: "v3" if key == "VERSION_FLAG" else default, ) - helper = PluginHelper.__new__(PluginHelper) + transport = PluginMarketTransport() async def fake_get_plugins(_repo, package_version=None): """按索引版本返回异步解析测试数据。""" @@ -971,15 +1032,15 @@ class TestPluginHelper: } return {"SharedV2": {"version": "1.0.0", "v2": True}} - monkeypatch.setattr(helper, "async_get_plugins", fake_get_plugins) + monkeypatch.setattr(transport, "async_get_plugins", fake_get_plugins) - assert asyncio.run(helper.async_get_plugin_package_version( + assert asyncio.run(transport.async_get_plugin_package_version( "DefaultV2", REPO_URL )) == "v2" - assert asyncio.run(helper.async_get_plugin_package_version( + assert asyncio.run(transport.async_get_plugin_package_version( "SharedV2", REPO_URL )) == "" - assert asyncio.run(helper.async_get_plugin_package_version( + assert asyncio.run(transport.async_get_plugin_package_version( "V3Copied", REPO_URL )) is None @@ -1011,14 +1072,13 @@ class TestPluginHelper: monkeypatch, ) -> None: """V3t 只把 package 中明确的 v3t:false 视为运行时不兼容。""" - from app.adapters.external import market as market_module from app.adapters.external.market import PluginHelper - monkeypatch.setattr(market_module, "is_free_threaded_runtime", lambda: True) + monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: True) monkeypatch.setattr( - market_module, - "settings", - SimpleNamespace(VERSION_FLAG="v3"), + plugin_client_module, + "get_runtime_setting", + lambda key, default=None: "v3" if key == "VERSION_FLAG" else default, ) assert PluginHelper.is_package_plugin_compatible({}, "v3") @@ -1032,7 +1092,6 @@ class TestPluginHelper: 全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。 """ try: - from app.adapters.external.market import PluginHelper from app.runtime.extensions.plugin_manager import PluginManager except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1087,10 +1146,11 @@ class TestPluginHelper: plugin_version = "1.2.0" plugin_manager = PluginManager() - monkeypatch.setattr(plugin_manager, "_plugins", {"DemoPlugin": DemoPlugin}) + plugin_manager.plugins.clear() + plugin_manager.plugins["DemoPlugin"] = DemoPlugin monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.get_plugin_storage", - lambda: SimpleNamespace( + "app.runtime.extensions.plugin.storage._plugin_storage", + SimpleNamespace( read=lambda key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None @@ -1110,7 +1170,11 @@ class TestPluginHelper: pytest.skip(f"missing dependency: {exc}") plugin_info = {"system_version": ">=2.13.0"} - with patch.object(PluginHelper, "get_current_system_version", return_value=Version("2.12.2")): + with patch.object( + PluginMarketTransport, + "get_current_system_version", + return_value=Version("2.12.2"), + ): annotated = PluginHelper.annotate_plugin_system_version(plugin_info) assert not annotated["system_version_compatible"] @@ -1121,7 +1185,7 @@ class TestPluginHelper: 验证依赖安装窗口内被其他任务导入的运行态模块不会被误删。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1138,8 +1202,8 @@ class TestPluginHelper: with tempfile.TemporaryDirectory() as temp_dir: requirements_file = Path(temp_dir) / "requirements.txt" requirements_file.write_text("demo-package\n", encoding="utf-8") - with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): - success, message = PluginHelper.install_packages_with_fallback(requirements_file) + with patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + success, message = PluginRuntimeHealth.install_packages_with_fallback(requirements_file) assert success assert "ok" == message @@ -1151,7 +1215,7 @@ class TestPluginHelper: 插件依赖安装优先使用 uv 时,传输代理只进入子进程环境。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1170,16 +1234,25 @@ class TestPluginHelper: uv_bin.write_text("", encoding="utf-8") with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ - patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={}), \ patch.object( - PluginHelper, - "_PluginHelper__run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__run_runtime_healthcheck", return_value={"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")}, ), \ - patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute), \ - patch("app.adapters.external.market.settings.PROXY_HOST", "http://proxy.example:7890"), \ - patch("app.adapters.external.market.settings.PIP_PROXY", "https://user:pass@mirror.example/simple"): - success, message = PluginHelper.install_packages_with_fallback(req) + patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute), \ + patch( + "app.adapters.system.plugin.health.get_runtime_setting", + side_effect=lambda key: { + "ROOT_PATH": root, + "TEMP_PATH": root / "temp", + "CONFIG_PATH": root / "config", + "PACKAGE_CACHE_PATH": root / "cache", + "PROXY_HOST": "http://proxy.example:7890", + "PIP_PROXY": "https://user:pass@mirror.example/simple", + }.get(key), + ): + success, message = PluginRuntimeHealth.install_packages_with_fallback(req) assert success assert message == "ok" @@ -1193,7 +1266,7 @@ class TestPluginHelper: def test_uv_install_keeps_multiple_original_manifests_in_one_command(self): """批量恢复必须让 uv 直接读取每个插件的原始生效清单。""" try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1233,15 +1306,15 @@ demo = { index = "private" } uv_bin = _create_fake_uv(root) with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ - patch.object(PluginHelper, "_PluginHelper__get_installed_packages", return_value={}), \ - patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__get_installed_packages", return_value={}), \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={}), \ patch.object( - PluginHelper, - "_PluginHelper__run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__run_runtime_healthcheck", return_value={"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")}, ), \ - patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): - success, message = PluginHelper.install_packages_with_fallback( + patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + success, message = PluginRuntimeHealth.install_packages_with_fallback( [modern, legacy] ) @@ -1264,7 +1337,7 @@ demo = { index = "private" } 验证多个依赖安装请求会复用同一把锁串行执行 uv。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1288,7 +1361,7 @@ demo = { index = "private" } def worker(requirements_file: Path): try: start_event.wait() - PluginHelper.install_packages_with_fallback(requirements_file) + PluginRuntimeHealth.install_packages_with_fallback(requirements_file) except Exception as err: # pragma: no cover - 仅用于并发测试失败诊断 errors.append(err) @@ -1303,7 +1376,7 @@ demo = { index = "private" } threading.Thread(target=worker, args=(requirements_file,)) for requirements_file in requirements_files ] - with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + with patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): for thread in threads: thread.start() start_event.set() @@ -1318,7 +1391,7 @@ demo = { index = "private" } 验证仅主程序依赖链上的包会被纳入保护集合。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1334,16 +1407,16 @@ demo = { index = "private" } } with patch.object( - PluginHelper, - "_PluginHelper__parse_project_requirement_roots", + PluginRuntimeHealth, + "_PluginRuntimeHealth__parse_project_requirement_roots", return_value={"passlib": set()} ): with patch.object( - PluginHelper, - "_PluginHelper__get_installed_distribution_requirements", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_installed_distribution_requirements", return_value=requirement_graph ): - protected_packages = PluginHelper._PluginHelper__get_protected_runtime_packages(installed_packages) + protected_packages = PluginRuntimeHealth._PluginRuntimeHealth__get_protected_runtime_packages(installed_packages) assert { "passlib": Version("1.7.4"), @@ -1355,7 +1428,7 @@ demo = { index = "private" } 验证插件如果试图覆盖主程序核心依赖,会在真正执行安装前被直接拒绝。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1363,11 +1436,11 @@ demo = { index = "private" } requirements_file = Path(temp_dir) / "requirements.txt" requirements_file.write_text("fastapi<0.1\n", encoding="utf-8") with patch.object( - PluginHelper, - "_PluginHelper__get_protected_runtime_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={"fastapi": Version("0.115.14")} ): - success, message = PluginHelper.install_packages_with_fallback(requirements_file) + success, message = PluginRuntimeHealth.install_packages_with_fallback(requirements_file) assert not success assert "主程序核心依赖" in message @@ -1388,7 +1461,6 @@ demo = { index = "private" } requirement, ): """插件不得升级当前解释器 profile 中经过 ABI/GIL 验证的根包。""" - from app.adapters.external.market import PluginHelper with tempfile.TemporaryDirectory() as temp_dir: requirements_file = Path(temp_dir) / "requirements.txt" @@ -1397,7 +1469,7 @@ demo = { index = "private" } "app.runtime.dependencies.profile.runtime_dependency_group", return_value=runtime_group, ): - success, message = PluginHelper._PluginHelper__validate_runtime_dependency_conflicts( + success, message = PluginRuntimeHealth._PluginRuntimeHealth__validate_runtime_dependency_conflicts( requirements_file, {installed_package: Version(installed_version)}, ) @@ -1408,11 +1480,10 @@ demo = { index = "private" } def test_runtime_healthcheck_preserves_plugin_upgrade_semantics(self, tmp_path): """运行环境诊断不应把允许的插件依赖升级强制还原到宿主锁版本。""" - from app.adapters.external.market import PluginHelper uv_bin = tmp_path / "uv" - with patch("app.adapters.external.market.find_uv", return_value=uv_bin): - command = PluginHelper._PluginHelper__build_runtime_uv_check_command() + with patch("app.adapters.system.plugin.health.find_uv", return_value=uv_bin): + command = PluginRuntimeHealth._PluginRuntimeHealth__build_runtime_uv_check_command() assert command == [ str(uv_bin), @@ -1424,7 +1495,6 @@ demo = { index = "private" } def test_plugin_runtime_healthcheck_uses_full_capability_probe(self, tmp_path): """共享 venv 发生变更时必须验证 ABI 敏感原生能力。""" - from app.adapters.external.market import PluginHelper uv_bin = tmp_path / "uv" commands = [] @@ -1433,11 +1503,11 @@ demo = { index = "private" } commands.append(command) return True, "ok" - with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch( - "app.adapters.external.market.SystemUtils.execute_with_subprocess", + with patch("app.adapters.system.package.find_uv", return_value=uv_bin), patch( + "app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=execute, ): - PluginHelper._PluginHelper__run_runtime_healthcheck() + PluginRuntimeHealth._PluginRuntimeHealth__run_runtime_healthcheck() assert [ sys.executable, @@ -1451,7 +1521,7 @@ demo = { index = "private" } 验证非主程序依赖即便已安装,插件后续仍可调整其版本约束。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1471,18 +1541,18 @@ demo = { index = "private" } return True, "ok" with patch.object( - PluginHelper, - "_PluginHelper__get_installed_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_installed_packages", return_value={"demo_package": Version("1.0")} ): with patch.object( - PluginHelper, - "_PluginHelper__get_protected_runtime_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={} ): - with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + with patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): with patch("app.adapters.system.package.find_uv", return_value=uv_bin): - success, message = PluginHelper.install_packages_with_fallback(requirements_file) + success, message = PluginRuntimeHealth.install_packages_with_fallback(requirements_file) assert success assert "ok" == message @@ -1493,7 +1563,7 @@ demo = { index = "private" } 验证插件依赖安装会固定主程序依赖的当前版本,防止共享 venv 被改写。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1516,13 +1586,13 @@ demo = { index = "private" } return True, "ok" with patch.object( - PluginHelper, - "_PluginHelper__get_protected_runtime_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={"fastapi": Version("0.115.14")} ): - with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + with patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): with patch("app.adapters.system.package.find_uv", return_value=uv_bin): - success, message = PluginHelper.install_packages_with_fallback(requirements_file) + success, message = PluginRuntimeHealth.install_packages_with_fallback(requirements_file) assert success assert "ok" == message @@ -1534,7 +1604,7 @@ demo = { index = "private" } 验证插件依赖安装后若破坏运行环境,会先恢复主程序依赖,再向上层返回失败。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1564,15 +1634,15 @@ demo = { index = "private" } raise AssertionError(f"unexpected command: {cmd}") with patch.object( - PluginHelper, - "_PluginHelper__get_protected_runtime_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={"fastapi": Version("0.115.14")} ): - with patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): - with patch("app.adapters.external.market.find_uv", return_value=uv_bin), patch( + with patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + with patch("app.adapters.system.package.find_uv", return_value=uv_bin), patch( "app.adapters.system.package.find_uv", return_value=uv_bin ): - success, message = PluginHelper.install_packages_with_fallback(requirements_file) + success, message = PluginRuntimeHealth.install_packages_with_fallback(requirements_file) assert not success assert "已自动恢复主程序依赖" in message @@ -1584,7 +1654,7 @@ demo = { index = "private" } 安装前已存在且安装后未新增的环境异常不应归因于本次插件依赖安装。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1611,19 +1681,19 @@ demo = { index = "private" } requirements_file.write_text("demo-package\n", encoding="utf-8") uv_bin = _create_fake_uv(root) with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ - patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={}), \ patch.object( - PluginHelper, - "_PluginHelper__run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__run_runtime_healthcheck", side_effect=health_snapshots, ), \ - patch.object(PluginHelper, "_PluginHelper__repair_main_runtime_dependencies") as repair_mock, \ - patch("app.adapters.external.market.logger.warning") as warning_mock, \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__repair_main_runtime_dependencies") as repair_mock, \ + patch("app.adapters.system.plugin.health.logger.warning") as warning_mock, \ patch( - "app.adapters.external.market.SystemUtils.execute_with_subprocess", + "app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", return_value=(True, "installed"), ): - success, message = PluginHelper.install_packages_with_fallback(requirements_file) + success, message = PluginRuntimeHealth.install_packages_with_fallback(requirements_file) assert success assert message == "installed" @@ -1638,7 +1708,7 @@ demo = { index = "private" } 既有全局依赖异常不能遮蔽本次安装新造成的核心依赖导入失败。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1663,22 +1733,22 @@ demo = { index = "private" } requirements_file.write_text("demo-package\n", encoding="utf-8") uv_bin = _create_fake_uv(root) with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ - patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={}), \ patch.object( - PluginHelper, - "_PluginHelper__run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__run_runtime_healthcheck", side_effect=health_snapshots, ), \ patch.object( - PluginHelper, - "_PluginHelper__repair_main_runtime_dependencies", + PluginRuntimeHealth, + "_PluginRuntimeHealth__repair_main_runtime_dependencies", return_value=(True, "repaired"), ) as repair_mock, \ patch( - "app.adapters.external.market.SystemUtils.execute_with_subprocess", + "app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", return_value=(True, "installed"), ): - success, message = PluginHelper.install_packages_with_fallback(requirements_file) + success, message = PluginRuntimeHealth.install_packages_with_fallback(requirements_file) assert not success assert "核心依赖导入检查失败" in message @@ -1686,16 +1756,15 @@ demo = { index = "private" } def test_preexisting_uv_diagnostic_does_not_hide_new_package_error(self): """既有第三方元数据告警不能遮蔽插件安装新增的依赖错误。""" - from app.adapters.external.market import PluginHelper existing_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed" added_error = "The package `demo` requires `missing>=1`, but it's not installed" with patch( - "app.adapters.external.market.runtime_excluded_dependency_pairs", + "app.adapters.system.plugin.health.runtime_excluded_dependency_pairs", return_value={("oss2", "crcmod")}, ): - message = PluginHelper._PluginHelper__runtime_health_regression_message( + message = PluginRuntimeHealth._PluginRuntimeHealth__runtime_health_regression_message( {"uv check": (False, existing_error)}, {"uv check": (False, f"{existing_error}\n{added_error}")}, ) @@ -1705,14 +1774,13 @@ demo = { index = "private" } def test_expected_uv_diagnostic_does_not_create_baseline_warning(self): """项目明确排除的传递依赖不得形成插件安装前告警。""" - from app.adapters.external.market import PluginHelper expected_error = "The package `oss2` requires `crcmod>=1.7`, but it's not installed" with patch( - "app.adapters.external.market.runtime_excluded_dependency_pairs", + "app.adapters.system.plugin.health.runtime_excluded_dependency_pairs", return_value={("oss2", "crcmod")}, ): - message = PluginHelper._PluginHelper__runtime_health_regression_message( + message = PluginRuntimeHealth._PluginRuntimeHealth__runtime_health_regression_message( {}, {"uv check": (False, expected_error)}, ) @@ -1721,12 +1789,11 @@ demo = { index = "private" } def test_uv_diagnostic_parser_handles_executor_prefix(self): """执行器把首条错误拼在命令摘要后时仍应识别完整诊断项。""" - from app.adapters.external.market import PluginHelper package_error = "The package `demo` requires `missing>=1`, but it's not installed" message = f"命令:uv pip check,执行失败,返回码:1,错误输出:{package_error}" - issues = PluginHelper._PluginHelper__runtime_health_error_lines( + issues = PluginRuntimeHealth._PluginRuntimeHealth__runtime_health_error_lines( "uv check", message, ) @@ -1738,7 +1805,7 @@ demo = { index = "private" } 安装策略失败后如果主运行环境异常,应先恢复主程序依赖再返回失败。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1756,10 +1823,10 @@ demo = { index = "private" } uv_bin = _create_fake_uv(root) with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ - patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={}), \ patch.object( - PluginHelper, - "_PluginHelper__run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__run_runtime_healthcheck", side_effect=[ {"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")}, {"uv check": (False, "broken"), "核心依赖导入检查": (True, "ok")}, @@ -1767,13 +1834,13 @@ demo = { index = "private" } ], ), \ patch.object( - PluginHelper, - "_PluginHelper__repair_main_runtime_dependencies", + PluginRuntimeHealth, + "_PluginRuntimeHealth__repair_main_runtime_dependencies", side_effect=lambda snapshot_file=None: repair_calls.append(snapshot_file) or (True, "runtime repaired"), ), \ - patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): - success, message = PluginHelper.install_packages_with_fallback(req) + patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + success, message = PluginRuntimeHealth.install_packages_with_fallback(req) assert not success assert "partial failure" in message or "恢复" in message @@ -1784,7 +1851,7 @@ demo = { index = "private" } 一旦失败策略污染主运行环境并触发恢复,不能继续 fallback 后把安装结果伪装成成功。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1808,10 +1875,10 @@ demo = { index = "private" } uv_bin.write_text("", encoding="utf-8") with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ - patch.object(PluginHelper, "_PluginHelper__get_protected_runtime_packages", return_value={}), \ + patch.object(PluginRuntimeHealth, "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={}), \ patch.object( - PluginHelper, - "_PluginHelper__run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__run_runtime_healthcheck", side_effect=[ {"uv check": (True, "ok"), "核心依赖导入检查": (True, "ok")}, {"uv check": (False, "broken"), "核心依赖导入检查": (True, "ok")}, @@ -1819,15 +1886,15 @@ demo = { index = "private" } ], ), \ patch.object( - PluginHelper, - "_PluginHelper__repair_main_runtime_dependencies", + PluginRuntimeHealth, + "_PluginRuntimeHealth__repair_main_runtime_dependencies", side_effect=lambda snapshot_file=None: repair_calls.append(snapshot_file) or (True, "runtime repaired"), ), \ - patch("app.adapters.external.market.settings.PIP_PROXY", "https://mirror.example/simple"), \ - patch("app.adapters.external.market.settings.PROXY_HOST", "http://proxy.example:7890"), \ - patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): - success, message = PluginHelper.install_packages_with_fallback(req) + patch("app.adapters.system.plugin.health.settings.PIP_PROXY", "https://mirror.example/simple"), \ + patch("app.adapters.system.plugin.health.settings.PROXY_HOST", "http://proxy.example:7890"), \ + patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + success, message = PluginRuntimeHealth.install_packages_with_fallback(req) assert not success assert "resolver failed" in message @@ -1840,7 +1907,7 @@ demo = { index = "private" } 主运行环境恢复与插件安装使用同一套 cache、index、proxy 和安全日志语义。 """ try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") @@ -1860,12 +1927,19 @@ demo = { index = "private" } with patch("app.adapters.system.package.find_uv", return_value=uv_bin), \ patch.dict(os.environ, {}, clear=True), \ - patch("app.adapters.external.market.settings.CONFIG_PATH", root / "config"), \ - patch("app.adapters.external.market.settings.PACKAGE_CACHE_PATH", root / "custom-package-cache"), \ - patch("app.adapters.external.market.settings.PIP_PROXY", "https://user:pass@mirror.example/simple"), \ - patch("app.adapters.external.market.settings.PROXY_HOST", "http://proxy.example:7890"), \ - patch("app.adapters.external.market.SystemUtils.execute_with_subprocess", side_effect=fake_execute): - success, message = PluginHelper._PluginHelper__repair_main_runtime_dependencies(req) + patch( + "app.adapters.system.plugin.health.get_runtime_setting", + side_effect=lambda key: { + "ROOT_PATH": root, + "TEMP_PATH": root / "temp", + "CONFIG_PATH": root / "config", + "PACKAGE_CACHE_PATH": root / "custom-package-cache", + "PIP_PROXY": "https://user:pass@mirror.example/simple", + "PROXY_HOST": "http://proxy.example:7890", + }.get(key), + ), \ + patch("app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess", side_effect=fake_execute): + success, message = PluginRuntimeHealth._PluginRuntimeHealth__repair_main_runtime_dependencies(req) assert success assert message == "ok" @@ -1881,18 +1955,18 @@ demo = { index = "private" } def test_async_package_install_uses_cancellable_subprocess(self): """异步依赖安装应直接使用可取消的子进程执行器。""" try: - from app.adapters.external.market import PluginHelper + from app.adapters.system.plugin.health import PluginRuntimeHealth except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = PluginRuntimeHealth() with tempfile.TemporaryDirectory() as temp_dir: requirements_file = Path(temp_dir) / "demo-requirements.txt" requirements_file.write_text("demo-package\n", encoding="utf-8") find_links_dirs = [Path(temp_dir) / "wheels"] async def run_install(): - return await helper._PluginHelper__async_install_packages_with_fallback( + return await helper._PluginRuntimeHealth__async_install_packages_with_fallback( requirements_file, find_links_dirs, ) @@ -1910,34 +1984,34 @@ demo = { index = "private" } ) with patch.object( - PluginHelper, - "_PluginHelper__get_installed_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_installed_packages", return_value={}, ), patch.object( - PluginHelper, - "_PluginHelper__get_protected_runtime_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={}, ), patch.object( - PluginHelper, - "_PluginHelper__validate_runtime_dependency_conflicts", + PluginRuntimeHealth, + "_PluginRuntimeHealth__validate_runtime_dependency_conflicts", return_value=(True, ""), ), patch( - "app.adapters.external.market.build_package_install_strategies", + "app.adapters.system.plugin.health.build_package_install_strategies", return_value=[strategy], ), patch.object( - PluginHelper, - "_PluginHelper__async_run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__async_run_runtime_healthcheck", side_effect=[health, health], ), patch.object( - PluginHelper, - "_PluginHelper__refresh_import_system", + PluginRuntimeHealth, + "_PluginRuntimeHealth__refresh_import_system", ), patch( - "app.adapters.external.market.logger.warning", + "app.adapters.system.plugin.health.logger.warning", ) as warning_mock, patch( - "app.adapters.external.market.runtime_excluded_dependency_pairs", + "app.adapters.system.plugin.health.runtime_excluded_dependency_pairs", return_value={("oss2", "crcmod")}, ), patch( - "app.adapters.external.market.SystemUtils.execute_with_subprocess_async", + "app.adapters.system.plugin.health.SystemUtils.execute_with_subprocess_async", new=AsyncMock(return_value=(True, "ok")), ) as execute_mock: success, message = asyncio.run(run_install()) @@ -1946,7 +2020,7 @@ demo = { index = "private" } assert "ok" == message execute_mock.assert_awaited_once() assert execute_mock.await_args.kwargs["timeout"] == ( - PluginHelper.PLUGIN_DEPENDENCY_INSTALL_TIMEOUT + PluginRuntimeHealth.PLUGIN_DEPENDENCY_INSTALL_TIMEOUT ) assert not any( "安装前运行环境已存在异常" in str(call.args[0]) @@ -1957,9 +2031,8 @@ demo = { index = "private" } """取消真实安装进程后必须回收进程树、临时约束和安装锁。""" import psutil - from app.adapters.external.market import PluginHelper - helper = PluginHelper() + helper = PluginRuntimeHealth() requirements_file = tmp_path / "requirements.txt" requirements_file.write_text("demo-package\n", encoding="utf-8") constraints_file = tmp_path / "runtime-constraints.txt" @@ -2001,8 +2074,8 @@ demo = { index = "private" } await task assert not constraints_file.exists() - assert PluginHelper._package_install_lock.acquire(blocking=False) - PluginHelper._package_install_lock.release() + assert PluginRuntimeHealth._package_install_lock.acquire(blocking=False) + PluginRuntimeHealth._package_install_lock.release() for _ in range(100): alive = [] for pid in pids: @@ -2022,36 +2095,35 @@ demo = { index = "private" } pytest.fail(f"安装进程树仍在运行:{alive}") with patch.object( - PluginHelper, - "_PluginHelper__get_installed_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_installed_packages", return_value={}, ), patch.object( - PluginHelper, - "_PluginHelper__get_protected_runtime_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={"fastapi": "0"}, ), patch.object( - PluginHelper, - "_PluginHelper__validate_runtime_dependency_conflicts", + PluginRuntimeHealth, + "_PluginRuntimeHealth__validate_runtime_dependency_conflicts", return_value=(True, ""), ), patch.object( - PluginHelper, - "_PluginHelper__create_runtime_constraints_file", + PluginRuntimeHealth, + "_PluginRuntimeHealth__create_runtime_constraints_file", side_effect=create_constraints, ), patch( - "app.adapters.external.market.build_package_install_strategies", + "app.adapters.system.plugin.health.build_package_install_strategies", return_value=[strategy], ), patch.object( - PluginHelper, - "_PluginHelper__async_run_runtime_healthcheck", + PluginRuntimeHealth, + "_PluginRuntimeHealth__async_run_runtime_healthcheck", new=AsyncMock(return_value=health), ): asyncio.run(run_install()) def test_constraints_created_during_cancellation_are_removed(self, tmp_path): """约束文件创建线程收口后仍须响应取消并删除临时文件。""" - from app.adapters.external.market import PluginHelper - helper = PluginHelper() + helper = PluginRuntimeHealth() requirements_file = tmp_path / "requirements.txt" requirements_file.write_text("demo-package\n", encoding="utf-8") constraints_file = tmp_path / "runtime-constraints.txt" @@ -2075,20 +2147,20 @@ demo = { index = "private" } await task with patch.object( - PluginHelper, - "_PluginHelper__get_installed_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_installed_packages", return_value={}, ), patch.object( - PluginHelper, - "_PluginHelper__get_protected_runtime_packages", + PluginRuntimeHealth, + "_PluginRuntimeHealth__get_protected_runtime_packages", return_value={"fastapi": "0"}, ), patch.object( - PluginHelper, - "_PluginHelper__validate_runtime_dependency_conflicts", + PluginRuntimeHealth, + "_PluginRuntimeHealth__validate_runtime_dependency_conflicts", return_value=(True, ""), ), patch.object( - PluginHelper, - "_PluginHelper__create_runtime_constraints_file", + PluginRuntimeHealth, + "_PluginRuntimeHealth__create_runtime_constraints_file", side_effect=create_constraints, ): asyncio.run(run_install()) @@ -2097,7 +2169,6 @@ demo = { index = "private" } def test_constraints_cleanup_failure_preserves_cancellation(self, tmp_path): """临时文件删除失败只记录日志,不得替换调用方的取消异常。""" - from app.adapters.external.market import PluginHelper constraints_file = tmp_path / "runtime-constraints.txt" created = threading.Event() @@ -2111,7 +2182,7 @@ demo = { index = "private" } async def run_create(): task = asyncio.create_task( - PluginHelper._PluginHelper__async_create_runtime_constraints_file( + PluginRuntimeHealth._PluginRuntimeHealth__async_create_runtime_constraints_file( {"fastapi": Version("0")} ) ) @@ -2122,14 +2193,14 @@ demo = { index = "private" } await task with patch.object( - PluginHelper, - "_PluginHelper__create_runtime_constraints_file", + PluginRuntimeHealth, + "_PluginRuntimeHealth__create_runtime_constraints_file", side_effect=create_constraints, ), patch.object( Path, "unlink", side_effect=PermissionError("locked"), - ), patch("app.adapters.external.market.logger.warning") as warning: + ), patch("app.adapters.system.plugin.health.logger.warning") as warning: asyncio.run(run_create()) warning.assert_called_once() @@ -2143,7 +2214,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2151,7 +2222,7 @@ demo = { index = "private" } (True, ""), ) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message @@ -2166,7 +2237,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2175,7 +2246,7 @@ demo = { index = "private" } (True, ""), ) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message @@ -2190,7 +2261,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2199,7 +2270,7 @@ demo = { index = "private" } (False, "DemoPlugin 插件源码目录不存在"), ) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert not success assert "DemoPlugin 插件源码目录不存在" == message @@ -2214,7 +2285,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2223,7 +2294,7 @@ demo = { index = "private" } (True, ""), ) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message @@ -2238,7 +2309,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2246,7 +2317,7 @@ demo = { index = "private" } (True, ""), ) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert not success assert f"未在插件清单中找到 {PLUGIN_ID} 的版本号" in message @@ -2261,7 +2332,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2270,7 +2341,7 @@ demo = { index = "private" } ) monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0")) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert not success assert "MoviePilot 版本 >=9.0.0" in message @@ -2285,7 +2356,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2299,7 +2370,7 @@ demo = { index = "private" } lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}], ) - success, message = helper._PluginHelper__install_package( + success, message = helper.install_raw( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.3", force_install=True ) @@ -2316,7 +2387,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2331,7 +2402,7 @@ demo = { index = "private" } lambda *_args: [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}], ) - success, message = helper._PluginHelper__install_package( + success, message = helper.install_raw( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) @@ -2348,7 +2419,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2361,7 +2432,7 @@ demo = { index = "private" } lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}], ) - success, message = helper._PluginHelper__install_package( + success, message = helper.install_raw( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) @@ -2378,7 +2449,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - success, message = PluginHelper()._PluginHelper__install_package("", REPO_URL) + success, message = _package_owner(PluginHelper()).install_raw("", REPO_URL) assert not success assert "参数错误" == message @@ -2392,7 +2463,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - success, message = PluginHelper()._PluginHelper__install_package(PLUGIN_ID, "not-a-repo-url") + success, message = _package_owner(PluginHelper()).install_raw(PLUGIN_ID, "not-a-repo-url") assert not success assert "不支持的插件仓库地址格式" == message @@ -2406,10 +2477,10 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) monkeypatch.setattr(helper, "get_plugin_package_version", lambda *_args: None) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL) assert not success assert f"{PLUGIN_ID} 没有找到适用于当前版本的插件" == message @@ -2423,16 +2494,16 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) seen_versions = [] monkeypatch.setattr(helper, "get_plugin_package_version", lambda _pid, _repo, version: seen_versions.append(version) or "") - monkeypatch.setattr(helper, "_PluginHelper__get_plugin_meta", lambda *_args: {"release": False, "version": "1.2.3"}) - monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None) - monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: None) - monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, "")) - monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_sync", lambda *_args: (True, "")) + monkeypatch.setattr(helper, "_PluginPackageManager__get_plugin_meta", lambda *_args: {"release": False, "version": "1.2.3"}) + monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: None) + monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: None) + monkeypatch.setattr(helper, "_PluginPackageManager__install_dependencies_if_required", lambda _pid: (False, True, "")) + monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_sync", lambda *_args: (True, "")) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, force_install=True) assert success assert "" == message @@ -2457,7 +2528,7 @@ demo = { index = "private" } dependency_file.write_text("module.exports = {}\n", encoding="utf-8") runtime_root = tmp_path / "runtime-plugins" - helper = PluginHelper() + helper = _package_owner(PluginHelper()) monkeypatch.setattr( helper, "get_local_plugin_candidate", @@ -2468,9 +2539,9 @@ demo = { index = "private" } "version": "1.0.0", }, ) - monkeypatch.setattr("app.adapters.external.market.PLUGIN_DIR", runtime_root) + helper._plugin_root = runtime_root - success, message = helper._PluginHelper__install_package( + success, message = helper.install_raw( PLUGIN_ID, helper.make_local_repo_url(PLUGIN_ID, repo_path, "v2"), force_install=True, @@ -2491,7 +2562,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_sync_remote_install( helper, monkeypatch, @@ -2500,7 +2571,7 @@ demo = { index = "private" } (True, ""), ) - success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + success, message = helper.install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) assert success assert "" == message @@ -2515,7 +2586,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_async_remote_install( helper, monkeypatch, @@ -2524,7 +2595,7 @@ demo = { index = "private" } ) success, message = asyncio.run( - helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper.async_install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success @@ -2540,7 +2611,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_async_remote_install( helper, monkeypatch, @@ -2550,7 +2621,7 @@ demo = { index = "private" } ) success, message = asyncio.run( - helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper.async_install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success @@ -2566,7 +2637,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_async_remote_install( helper, monkeypatch, @@ -2582,7 +2653,7 @@ demo = { index = "private" } monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases) success, message = asyncio.run( - helper._PluginHelper__async_install_package( + helper.async_install_raw( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) ) @@ -2600,7 +2671,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_async_remote_install( helper, monkeypatch, @@ -2614,7 +2685,7 @@ demo = { index = "private" } monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases) success, message = asyncio.run( - helper._PluginHelper__async_install_package( + helper.async_install_raw( PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True ) ) @@ -2632,7 +2703,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = _patch_async_remote_install( helper, monkeypatch, @@ -2642,7 +2713,7 @@ demo = { index = "private" } ) success, message = asyncio.run( - helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper.async_install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert not success @@ -2658,7 +2729,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) filelist_pids = [] _patch_async_remote_install( helper, @@ -2672,10 +2743,10 @@ demo = { index = "private" } filelist_pids.append(pid) return True, "" - monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist) + monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_async", fake_filelist) success, message = asyncio.run( - helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper.async_install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success @@ -2691,7 +2762,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) filelist_pids = [] _patch_async_remote_install( helper, @@ -2705,10 +2776,10 @@ demo = { index = "private" } filelist_pids.append(pid) return True, "" - monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist) + monkeypatch.setattr(helper, "_PluginPackageManager__prepare_content_via_filelist_async", fake_filelist) success, message = asyncio.run( - helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) + helper.async_install_raw(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True) ) assert success @@ -2724,10 +2795,10 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: _FakeResponse(404)) + helper = _package_owner(PluginHelper()) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: _FakeResponse(404)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "DemoPlugin_v1.2.3 插件发布包不存在" == message @@ -2739,14 +2810,14 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) monkeypatch.setattr( helper, - "_PluginHelper__request_with_fallback", + "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: _FakeResponse(404), ) - file_list, message = helper._PluginHelper__get_file_list( + file_list, message = helper._PluginPackageManager__get_file_list( PLUGIN_ID, "demo/repo", "v2", @@ -2764,14 +2835,14 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) monkeypatch.setattr( helper, - "_PluginHelper__request_with_fallback", + "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: _FakeResponse(200, {"assets": [{"name": "other.zip", "id": 1}]}), ) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "未找到资产文件:demoplugin_v1.2.3.zip" == message @@ -2785,14 +2856,14 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) monkeypatch.setattr( helper, - "_PluginHelper__request_with_fallback", + "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip"}]}), ) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "资产缺少ID信息" == message @@ -2813,10 +2884,10 @@ demo = { index = "private" } """模拟响应体不是合法 JSON。""" raise ValueError("bad json") - helper = PluginHelper() - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: BadResponse(200)) + helper = _package_owner(PluginHelper()) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: BadResponse(200)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "解析 Release 信息失败" in message @@ -2830,14 +2901,14 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeResponse(502), ]) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "下载资产失败:502" == message @@ -2862,15 +2933,15 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeContentResponse(200, _build_release_zip_member(member_name, symlink=symlink)), ]) _patch_release_install_settings(monkeypatch, tmp_path) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "非法 Release 压缩包成员" in message @@ -2888,7 +2959,9 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner( + PluginHelper(), plugin_root=tmp_path / "app" / "plugins" + ) release_payload = {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]} zip_content = _build_zip({ "demoplugin/__init__.py": b"plugin", @@ -2902,9 +2975,9 @@ demo = { index = "private" } ROOT_PATH=tmp_path, REPO_GITHUB_HEADERS=lambda repo=None: {}, )) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert success assert "" == message @@ -2920,7 +2993,9 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner( + PluginHelper(), plugin_root=tmp_path / "app" / "plugins" + ) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zf: zf.writestr("demoplugin/assets/", b"") @@ -2933,9 +3008,9 @@ demo = { index = "private" } ROOT_PATH=tmp_path, REPO_GITHUB_HEADERS=lambda repo=None: {}, )) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert success assert "" == message @@ -2951,14 +3026,14 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeContentResponse(200, _build_zip({})), ]) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "压缩包内容为空" == message @@ -2972,7 +3047,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zf: zf.writestr("demoplugin/assets/", b"") @@ -2984,9 +3059,9 @@ demo = { index = "private" } ROOT_PATH=tmp_path, REPO_GITHUB_HEADERS=lambda repo=None: {}, )) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "压缩包中无可写入文件" == message @@ -3000,14 +3075,14 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeContentResponse(200, b"not a zip"), ]) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: next(responses)) + monkeypatch.setattr(helper, "_PluginPackageManager__request_with_fallback", lambda *_args, **_kwargs: next(responses)) - success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + success, message = helper._PluginPackageManager__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") assert not success assert "解压 Release 压缩包失败" in message @@ -3021,13 +3096,13 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = [] - monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: "/backup") - monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove")) - monkeypatch.setattr(helper, "_PluginHelper__restore_plugin", lambda _pid, _backup: calls.append("restore")) + monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: "/backup") + monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: calls.append("remove")) + monkeypatch.setattr(helper, "_PluginPackageManager__restore_plugin", lambda _pid, _backup: calls.append("restore")) - success, message = helper._PluginHelper__install_flow_sync( + success, message = helper._PluginPackageManager__install_flow_sync( PLUGIN_ID, False, lambda: (False, "prepare failed") ) @@ -3044,18 +3119,18 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = [] - monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: "/backup") - monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove")) - monkeypatch.setattr(helper, "_PluginHelper__restore_plugin", lambda _pid, _backup: calls.append("restore")) + monkeypatch.setattr(helper, "_PluginPackageManager__backup_plugin", lambda _pid: "/backup") + monkeypatch.setattr(helper, "_PluginPackageManager__remove_old_plugin", lambda _pid: calls.append("remove")) + monkeypatch.setattr(helper, "_PluginPackageManager__restore_plugin", lambda _pid, _backup: calls.append("restore")) monkeypatch.setattr( helper, - "_PluginHelper__install_dependencies_if_required", + "_PluginPackageManager__install_dependencies_if_required", lambda _pid: (True, False, "dependency failed"), ) - success, message = helper._PluginHelper__install_flow_sync( + success, message = helper._PluginPackageManager__install_flow_sync( PLUGIN_ID, False, lambda: (True, "") ) @@ -3082,7 +3157,9 @@ demo = { index = "private" } ) return True, "" - success, message = market_module.PluginHelper()._PluginHelper__install_flow_sync( + success, message = _package_owner( + market_module.PluginHelper(), plugin_root=plugin_root + )._PluginPackageManager__install_flow_sync( PLUGIN_ID, False, prepare_content, @@ -3106,7 +3183,9 @@ demo = { index = "private" } encoding="utf-8", ) (plugin_dir / "requirements.txt").write_text("legacy>=1\n", encoding="utf-8") - helper = market_module.PluginHelper() + helper = _package_owner( + market_module.PluginHelper(), plugin_root=plugin_root + ) seen = [] monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root) monkeypatch.setattr( @@ -3115,7 +3194,7 @@ demo = { index = "private" } lambda path: seen.append(path) or (True, ""), ) - result = helper._PluginHelper__install_dependencies_if_required("DemoPlugin") + result = helper._PluginPackageManager__install_dependencies_if_required("DemoPlugin") assert result == (True, True, "") assert seen == [pyproject_file] @@ -3133,7 +3212,9 @@ demo = { index = "private" } encoding="utf-8", ) (plugin_dir / "requirements.txt").write_text("legacy>=1\n", encoding="utf-8") - helper = market_module.PluginHelper() + helper = _package_owner( + market_module.PluginHelper(), plugin_root=plugin_root + ) seen = [] async def fake_install(path, _find_links=None): @@ -3142,13 +3223,13 @@ demo = { index = "private" } monkeypatch.setattr(market_module, "PLUGIN_DIR", plugin_root) monkeypatch.setattr( - helper, - "_PluginHelper__async_install_packages_with_fallback", + helper._health, + "async_install_packages_with_fallback", fake_install, ) result = asyncio.run( - helper._PluginHelper__async_install_dependencies_if_required("DemoPlugin") + helper._PluginPackageManager__async_install_dependencies_if_required("DemoPlugin") ) assert result == (True, True, "") @@ -3161,14 +3242,14 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = [] file_list = [ {"name": "pyproject.toml", "download_url": "https://example.com/pyproject.toml"}, {"name": "requirements.txt", "download_url": "https://example.com/requirements.txt"}, {"name": "__init__.py", "download_url": "https://example.com/__init__.py"}, ] - monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: (file_list, "")) + monkeypatch.setattr(helper, "_PluginPackageManager__get_file_list", lambda *_args: (file_list, "")) def fake_download(*args): calls.append(args) @@ -3176,11 +3257,11 @@ demo = { index = "private" } monkeypatch.setattr( helper, - "_PluginHelper__download_files", + "_PluginPackageManager__download_files", fake_download, ) - success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") + success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") assert success assert "" == message @@ -3195,10 +3276,10 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() - monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: ([], "list failed")) + helper = _package_owner(PluginHelper()) + monkeypatch.setattr(helper, "_PluginPackageManager__get_file_list", lambda *_args: ([], "list failed")) - success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") + success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") assert not success assert "list failed" == message @@ -3210,7 +3291,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) requested_ids = [] def fake_file_list(pid, *_args): @@ -3219,11 +3300,11 @@ demo = { index = "private" } monkeypatch.setattr( helper, - "_PluginHelper__get_file_list", + "_PluginPackageManager__get_file_list", fake_file_list, ) - success, message = helper._PluginHelper__prepare_content_via_filelist_sync( + success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync( PLUGIN_ID, "demo/repo", "v2", @@ -3242,11 +3323,11 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() - monkeypatch.setattr(helper, "_PluginHelper__get_file_list", lambda *_args: ([{"name": "__init__.py"}], "")) - monkeypatch.setattr(helper, "_PluginHelper__download_files", lambda *_args: (False, "download failed")) + helper = _package_owner(PluginHelper()) + monkeypatch.setattr(helper, "_PluginPackageManager__get_file_list", lambda *_args: ([{"name": "__init__.py"}], "")) + monkeypatch.setattr(helper, "_PluginPackageManager__download_files", lambda *_args: (False, "download failed")) - success, message = helper._PluginHelper__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") + success, message = helper._PluginPackageManager__prepare_content_via_filelist_sync("demoplugin", "demo/repo", "v2") assert not success assert "download failed" == message @@ -3258,7 +3339,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = [] file_list = [ {"name": "pyproject.toml", "download_url": "https://example.com/pyproject.toml"}, @@ -3273,11 +3354,11 @@ demo = { index = "private" } calls.append(args) return True, "" - monkeypatch.setattr(helper, "_PluginHelper__async_get_file_list", fake_file_list) - monkeypatch.setattr(helper, "_PluginHelper__async_download_files", fake_download) + monkeypatch.setattr(helper, "_PluginPackageManager__async_get_file_list", fake_file_list) + monkeypatch.setattr(helper, "_PluginPackageManager__async_download_files", fake_download) success, message = asyncio.run( - helper._PluginHelper__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") + helper._PluginPackageManager__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") ) assert success @@ -3293,15 +3374,15 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) async def fake_file_list(*_args): return [], "list failed" - monkeypatch.setattr(helper, "_PluginHelper__async_get_file_list", fake_file_list) + monkeypatch.setattr(helper, "_PluginPackageManager__async_get_file_list", fake_file_list) success, message = asyncio.run( - helper._PluginHelper__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") + helper._PluginPackageManager__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") ) assert not success @@ -3314,7 +3395,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) requested_ids = [] async def fake_file_list(pid, *_args): @@ -3323,12 +3404,12 @@ demo = { index = "private" } monkeypatch.setattr( helper, - "_PluginHelper__async_get_file_list", + "_PluginPackageManager__async_get_file_list", fake_file_list, ) success, message = asyncio.run( - helper._PluginHelper__prepare_content_via_filelist_async( + helper._PluginPackageManager__prepare_content_via_filelist_async( PLUGIN_ID, "demo/repo", "v2", @@ -3348,7 +3429,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) async def fake_file_list(*_args): return [{"name": "__init__.py"}], "" @@ -3356,11 +3437,11 @@ demo = { index = "private" } async def fake_download(*_args): return False, "download failed" - monkeypatch.setattr(helper, "_PluginHelper__async_get_file_list", fake_file_list) - monkeypatch.setattr(helper, "_PluginHelper__async_download_files", fake_download) + monkeypatch.setattr(helper, "_PluginPackageManager__async_get_file_list", fake_file_list) + monkeypatch.setattr(helper, "_PluginPackageManager__async_download_files", fake_download) success, message = asyncio.run( - helper._PluginHelper__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") + helper._PluginPackageManager__prepare_content_via_filelist_async("demoplugin", "demo/repo", "v2") ) assert not success @@ -3375,7 +3456,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = [] async def backup(_pid): @@ -3390,11 +3471,11 @@ demo = { index = "private" } async def prepare(): return False, "prepare failed" - monkeypatch.setattr(helper, "_PluginHelper__async_backup_plugin", backup) - monkeypatch.setattr(helper, "_PluginHelper__async_remove_old_plugin", remove) - monkeypatch.setattr(helper, "_PluginHelper__async_restore_plugin", restore) + monkeypatch.setattr(helper, "_PluginPackageManager__async_backup_plugin", backup) + monkeypatch.setattr(helper, "_PluginPackageManager__async_remove_old_plugin", remove) + monkeypatch.setattr(helper, "_PluginPackageManager__async_restore_plugin", restore) - success, message = asyncio.run(helper._PluginHelper__install_flow_async(PLUGIN_ID, False, prepare)) + success, message = asyncio.run(helper._PluginPackageManager__install_flow_async(PLUGIN_ID, False, prepare)) assert not success assert "prepare failed" == message @@ -3409,7 +3490,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) calls = [] async def backup(_pid): @@ -3427,12 +3508,12 @@ demo = { index = "private" } async def dependencies(_pid): return True, False, "dependency failed" - monkeypatch.setattr(helper, "_PluginHelper__async_backup_plugin", backup) - monkeypatch.setattr(helper, "_PluginHelper__async_remove_old_plugin", remove) - monkeypatch.setattr(helper, "_PluginHelper__async_restore_plugin", restore) - monkeypatch.setattr(helper, "_PluginHelper__async_install_dependencies_if_required", dependencies) + monkeypatch.setattr(helper, "_PluginPackageManager__async_backup_plugin", backup) + monkeypatch.setattr(helper, "_PluginPackageManager__async_remove_old_plugin", remove) + monkeypatch.setattr(helper, "_PluginPackageManager__async_restore_plugin", restore) + monkeypatch.setattr(helper, "_PluginPackageManager__async_install_dependencies_if_required", dependencies) - success, message = asyncio.run(helper._PluginHelper__install_flow_async(PLUGIN_ID, False, prepare)) + success, message = asyncio.run(helper._PluginPackageManager__install_flow_async(PLUGIN_ID, False, prepare)) assert not success assert "dependency failed" == message @@ -3447,15 +3528,15 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) async def fake_request(*_args, **_kwargs): return _FakeResponse(200, {"assets": [{"name": "other.zip", "id": 1}]}) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert not success @@ -3470,15 +3551,15 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) async def fake_request(*_args, **_kwargs): return _FakeResponse(404) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert not success @@ -3491,19 +3572,19 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) async def fake_request(*_args, **_kwargs): return _FakeResponse(404) monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginPackageManager__async_request_with_fallback", fake_request, ) file_list, message = asyncio.run( - helper._PluginHelper__async_get_file_list( + helper._PluginPackageManager__async_get_file_list( PLUGIN_ID, "demo/repo", "v2", @@ -3522,15 +3603,15 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) async def fake_request(*_args, **_kwargs): return _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip"}]}) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert not success @@ -3545,7 +3626,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeResponse(502), @@ -3554,10 +3635,10 @@ demo = { index = "private" } async def fake_request(*_args, **_kwargs): return next(responses) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert not success @@ -3583,7 +3664,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeContentResponse(200, _build_release_zip_member(member_name, symlink=symlink)), @@ -3593,10 +3674,10 @@ demo = { index = "private" } return next(responses) _patch_release_install_settings(monkeypatch, tmp_path) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert not success @@ -3612,11 +3693,12 @@ demo = { index = "private" } """ try: from app.adapters.external.market import PluginHelper - from app.runtime.config import settings except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner( + PluginHelper(), plugin_root=tmp_path / "app" / "plugins" + ) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeContentResponse(200, _build_zip({"demoplugin/__init__.py": b"plugin"})), @@ -3629,10 +3711,10 @@ demo = { index = "private" } ROOT_PATH=tmp_path, REPO_GITHUB_HEADERS=lambda repo=None: {}, )) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert success @@ -3648,7 +3730,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeContentResponse(200, _build_zip({})), @@ -3657,10 +3739,10 @@ demo = { index = "private" } async def fake_request(*_args, **_kwargs): return next(responses) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert not success @@ -3675,7 +3757,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - helper = PluginHelper() + helper = _package_owner(PluginHelper()) responses = iter([ _FakeResponse(200, {"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 42}]}), _FakeContentResponse(200, b"not a zip"), @@ -3684,10 +3766,10 @@ demo = { index = "private" } async def fake_request(*_args, **_kwargs): return next(responses) - monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request) + monkeypatch.setattr(helper, "_PluginPackageManager__async_request_with_fallback", fake_request) success, message = asyncio.run( - helper._PluginHelper__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") + helper._PluginPackageManager__async_install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3") ) assert not success @@ -3702,7 +3784,7 @@ demo = { index = "private" } except ModuleNotFoundError as exc: pytest.skip(f"missing dependency: {exc}") - success, message = PluginHelper()._PluginHelper__install_package("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins") + success, message = _package_owner(PluginHelper()).install_raw("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins") assert not success assert "本地插件来源与插件ID不匹配" == message diff --git a/tests/test_plugin_helper_governance.py b/tests/test_plugin_helper_governance.py new file mode 100644 index 000000000..e41468341 --- /dev/null +++ b/tests/test_plugin_helper_governance.py @@ -0,0 +1,311 @@ +"""PluginHelper 兼容门面与插件 Application/Adapter owner 的治理回归。""" + +import ast +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin.client import ( + build_local_repo_url, + parse_local_repo_generation, + parse_local_repo_path, +) +from app.adapters.system.plugin.package import ( + PluginPackageCheckpoint, + PluginPackageManager, +) +from app.domain.plugin import ( + build_local_plugin_source, + build_plugin_release_install_plan, + check_plugin_system_version, + compatible_plugin_generations, + is_plugin_generation_compatible, + parse_local_plugin_generation, + parse_local_plugin_path, + parse_local_plugin_reference, + plugin_generation_candidates, +) + + +def _class_methods(path: Path, class_name: str) -> set[str]: + """读取生产类的方法集合,避免兼容门面重新吸收具体实现。""" + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == class_name: + return { + item.name + for item in node.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + raise AssertionError(f"未找到生产类:{class_name}") + + +def test_helper_keeps_package_implementation_out_of_compat_facade() -> None: + """Helper 不得重新物理拥有安装、解压、备份或恢复实现。""" + methods = _class_methods( + Path("app/adapters/external/market.py"), + "PluginHelper", + ) + forbidden = { + "__install_package", + "__async_install_package", + "__install_from_release", + "__async_install_from_release", + "__backup_plugin", + "__async_backup_plugin", + "__restore_plugin", + "__async_restore_plugin", + "__remove_old_plugin", + "__async_remove_old_plugin", + "__validate_release_zip_name", + "__validate_release_zip_type", + "__iter_release_zip_targets", + } + assert methods.isdisjoint(forbidden), sorted(methods & forbidden) + + +def test_package_owner_has_no_helper_private_port() -> None: + """包 owner 与组合根不得反向解析 Helper 私有实现。""" + helper_private_prefix = "_Plugin" "Helper__" + package_path = Path("app/adapters/system/plugin/package.py") + package_source = package_path.read_text(encoding="utf-8") + startup_source = Path("app/startup/initializers/plugins.py").read_text( + encoding="utf-8" + ) + assert "PluginHelper" not in package_source + assert helper_private_prefix not in package_source + assert helper_private_prefix not in startup_source + + package_tree = ast.parse(package_source) + package_class = next( + node + for node in package_tree.body + if isinstance(node, ast.ClassDef) and node.name == "PluginPackageManager" + ) + constructor = next( + node + for node in package_class.body + if isinstance(node, ast.FunctionDef) and node.name == "__init__" + ) + constructor_arguments = { + argument.arg + for argument in (*constructor.args.args, *constructor.args.kwonlyargs) + } + assert "helper" not in constructor_arguments + + +def test_plugin_tests_do_not_reach_helper_private_implementation() -> None: + """插件测试只能验证 Helper 公开 ABI,市场实现细节应直接落到 owner。""" + helper_private_prefix = "_Plugin" "Helper__" + forbidden_attributes = { + "_package_version_candidates", + "_build_remote_plugin_install_plan", + } + violations = [] + + for test_path in sorted(Path("tests").glob("test_plugin*.py")): + source = test_path.read_text(encoding="utf-8") + if helper_private_prefix in source: + violations.append(f"{test_path}: Helper name-mangled private access") + for node in ast.walk(ast.parse(source)): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "PluginHelper" + and node.attr in forbidden_attributes + ): + violations.append( + f"{test_path}:{node.lineno}: PluginHelper.{node.attr}" + ) + + assert violations == [] + + +def test_local_source_policy_round_trip(tmp_path: Path) -> None: + """本地来源编码、插件 ID、路径和代际解析共享同一纯领域合同。""" + repo_path = tmp_path / "plugin source" + repo_url = build_local_repo_url( + "Demo Plugin", + repo_path=repo_path, + package_version="v3", + ) + + assert parse_local_plugin_reference(repo_url) == "Demo Plugin" + assert parse_local_repo_path(repo_url, root_path=tmp_path) == repo_path.resolve() + assert parse_local_repo_generation(repo_url) == "v3" + assert PluginHelper.make_local_repo_url( + "Demo Plugin", + repo_path, + "v3", + ) == repo_url + + +def test_local_source_policy_covers_empty_and_invalid_references() -> None: + """纯领域解析器对无参数、非本地和畸形本地标识返回稳定结果。""" + assert build_local_plugin_source("Demo Plugin") == "local://Demo%20Plugin" + assert parse_local_plugin_reference("https://example.com/plugin") is None + assert parse_local_plugin_path("https://example.com/plugin") is None + assert parse_local_plugin_generation("https://example.com/plugin") is None + assert parse_local_plugin_reference("local://") is None + assert parse_local_plugin_path("local://Demo") is None + assert parse_local_plugin_generation("local://Demo") is None + assert parse_local_plugin_reference("local://[") is None + assert parse_local_plugin_path("local://[") is None + assert parse_local_plugin_generation("local://[") is None + + +def test_generation_candidate_policy_is_stable_and_deduplicated() -> None: + """兼容代际和安装候选顺序必须稳定,并把基础索引放在末尾。""" + assert compatible_plugin_generations(None) == () + assert compatible_plugin_generations("v3") == ("v3", "v2") + assert compatible_plugin_generations("v2") == ("v2",) + assert plugin_generation_candidates(None, current_generation="v3") == ( + "v3", + "v2", + "", + ) + assert plugin_generation_candidates("v2", current_generation="v3") == ( + "v2", + "", + ) + + +@pytest.mark.parametrize( + ("metadata", "generation", "expected"), + [ + ({"v3": True}, None, True), + ({"v2": True}, None, True), + ({"v3": False, "v2": True}, None, False), + ({"v3": False}, "v3", False), + ({"v3t": False}, "v3", False), + ], +) +def test_generation_policy_has_one_explicit_false_rule( + metadata: dict, + generation: str | None, + expected: bool, +) -> None: + """当前代际显式 false 和自由线程显式 false 都不得被专用索引绕过。""" + assert is_plugin_generation_compatible( + metadata, + generation, + current_generation="v3", + free_threaded=True, + ) is expected + + +@pytest.mark.parametrize( + ("metadata", "generation", "current", "expected"), + [ + (object(), None, "v3", False), + ({}, None, None, True), + ({}, "v3", None, False), + ({}, "v3", "v3", True), + ({}, "v2", "v3", True), + ({}, "v1", "v3", False), + ({"v3": True}, None, "v3", True), + ], +) +def test_generation_policy_covers_all_owner_decisions( + metadata: object, + generation: str | None, + current: str | None, + expected: bool, +) -> None: + """领域 owner 必须独立覆盖无代际、当前代际、兼容代际和未知代际。""" + assert is_plugin_generation_compatible( + metadata, + generation, + current_generation=current, + free_threaded=False, + ) is expected + + +@pytest.mark.parametrize( + ("metadata", "current_version", "compatible", "message_part"), + [ + (object(), "3.0.0", True, ""), + ({}, "3.0.0", True, ""), + ({"system_version": 3}, "3.0.0", False, "必须是字符串"), + ({"system_version": "not a specifier"}, "3.0.0", False, "格式不正确"), + ({"system_version": "==3.0.0"}, "not-a-version", False, "无法解析"), + ({"system_version": "==3.0.0"}, "3.0.0", True, ""), + ({"system_version": "==4.0.0"}, "3.0.0", False, "不满足"), + ], +) +def test_system_version_policy_covers_valid_and_invalid_contracts( + metadata: object, + current_version: str, + compatible: bool, + message_part: str, +) -> None: + """系统版本约束必须拒绝错误类型、错误格式、坏宿主版本和不匹配范围。""" + accepted, message = check_plugin_system_version( + metadata, + current_version=current_version, + ) + assert accepted is compatible + assert message_part in message + + +def test_release_plan_policy_rejects_unlisted_version() -> None: + """指定 Release 必须来自市场已经读取的可安装版本列表。""" + plan, message = build_plugin_release_install_plan( + plugin_id="Demo", + metadata={"release": True, "version": "2.0.0"}, + release_version="1.0.0", + release_items=(), + current_version="3.0.0", + ) + + assert plan is None + assert message == "Demo 未找到可安装的 Release 版本:1.0.0" + + +def _checkpoint(tmp_path: Path) -> PluginPackageCheckpoint: + """构造不触碰文件系统的包事务快照。""" + return PluginPackageCheckpoint( + plugin_id="Demo", + plugin_dir=tmp_path / "demo", + persistent_backup_dir=tmp_path / "backup", + backup_staging_dir=None, + backup_previous_dir=None, + transaction_dir=tmp_path / "transaction", + plugin_existed=True, + persistent_backup_existed=False, + ) + + +def test_package_owner_disables_compat_backup_when_checkpoint_exists( + tmp_path: Path, +) -> None: + """主安装事务已有 durable checkpoint 时,兼容下载层不得再维护第二套备份。""" + manager = PluginPackageManager(Mock()) + manager.install_raw = Mock(return_value=(True, "")) + checkpoint = _checkpoint(tmp_path) + + assert manager.install( + "Demo", + "https://github.com/example/plugins", + checkpoint=checkpoint, + ) == (True, "") + assert manager.install_raw.call_args.kwargs["force_install"] is True + + +@pytest.mark.asyncio +async def test_async_package_owner_disables_compat_backup_when_checkpoint_exists( + tmp_path: Path, +) -> None: + """异步主安装事务同样只允许 durable checkpoint 拥有恢复材料。""" + manager = PluginPackageManager(Mock()) + manager.async_install_raw = AsyncMock(return_value=(True, "")) + checkpoint = _checkpoint(tmp_path) + + assert await manager.async_install( + "Demo", + "https://github.com/example/plugins", + checkpoint=checkpoint, + ) == (True, "") + assert manager.async_install_raw.call_args.kwargs["force_install"] is True diff --git a/tests/test_plugin_install_backup_cleanup.py b/tests/test_plugin_install_backup_cleanup.py index a4dc41042..5ea152932 100644 --- a/tests/test_plugin_install_backup_cleanup.py +++ b/tests/test_plugin_install_backup_cleanup.py @@ -5,6 +5,8 @@ from pathlib import Path import pytest from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin.client import PluginPackageSourceClient +from app.adapters.system.plugin.package import PluginPackageManager @pytest.mark.asyncio @@ -13,34 +15,36 @@ async def test_successful_install_flows_remove_transient_backups( tmp_path: Path, ) -> None: """同步与异步安装成功后都必须删除临时回滚副本。""" - helper = PluginHelper() + package = PluginPackageManager( + source=PluginPackageSourceClient(PluginHelper()) + ) sync_backup = tmp_path / "sync-backup" async_backup = tmp_path / "async-backup" sync_backup.mkdir() async_backup.mkdir() monkeypatch.setattr( - helper, - "_PluginHelper__backup_plugin", + package, + "_PluginPackageManager__backup_plugin", lambda _pid: str(sync_backup), ) monkeypatch.setattr( - helper, - "_PluginHelper__remove_old_plugin", + package, + "_PluginPackageManager__remove_old_plugin", lambda _pid: None, ) monkeypatch.setattr( - helper, - "_PluginHelper__install_dependencies_if_required", + package, + "_PluginPackageManager__install_dependencies_if_required", lambda _pid: (False, False, "不存在依赖"), ) monkeypatch.setattr( - helper, - "refresh_persistent_plugin_backup", + package, + "refresh_persistent_backup", lambda _pid: True, ) - sync_result = helper._PluginHelper__install_flow_sync( + sync_result = package._PluginPackageManager__install_flow_sync( "DemoPlugin", False, lambda: (True, ""), @@ -62,22 +66,22 @@ async def test_successful_install_flows_remove_transient_backups( return True, "" monkeypatch.setattr( - helper, - "_PluginHelper__async_backup_plugin", + package, + "_PluginPackageManager__async_backup_plugin", backup_plugin, ) monkeypatch.setattr( - helper, - "_PluginHelper__async_remove_old_plugin", + package, + "_PluginPackageManager__async_remove_old_plugin", remove_plugin, ) monkeypatch.setattr( - helper, - "_PluginHelper__async_install_dependencies_if_required", + package, + "_PluginPackageManager__async_install_dependencies_if_required", install_dependencies, ) - async_result = await helper._PluginHelper__install_flow_async( + async_result = await package._PluginPackageManager__install_flow_async( "DemoPlugin", False, prepare_content, diff --git a/tests/test_plugin_install_plan_policy.py b/tests/test_plugin_install_plan_policy.py index 4e511443b..cea5996e1 100644 --- a/tests/test_plugin_install_plan_policy.py +++ b/tests/test_plugin_install_plan_policy.py @@ -1,11 +1,8 @@ """远端插件同步与异步安装模式共享策略测试。""" -from unittest.mock import patch - import pytest -from packaging.version import Version -from app.adapters.external.market import PluginHelper +from app.domain.plugin import build_plugin_release_install_plan @pytest.mark.parametrize( @@ -56,17 +53,13 @@ def test_remote_plugin_install_plan_preserves_mode_contract( error: str, ) -> None: """唯一计划器必须保留文件列表、当前 Release、指定 Release 与拒绝结果。""" - with patch.object( - PluginHelper, - "get_current_system_version", - return_value=Version("3.0.0"), - ): - plan, message = PluginHelper._build_remote_plugin_install_plan( - pid="DemoPlugin", - meta=meta, - release_version=release_version, - release_items=release_items, - ) + plan, message = build_plugin_release_install_plan( + plugin_id="DemoPlugin", + metadata=meta, + release_version=release_version, + release_items=release_items, + current_version="3.0.0", + ) assert message == error assert (plan.release_tag if plan else None) == tag @@ -75,21 +68,17 @@ def test_remote_plugin_install_plan_preserves_mode_contract( def test_current_release_keeps_system_version_admission() -> None: """指定当前索引版本仍须通过系统版本限制,旧 Release 则沿用历史兼容语义。""" - with patch.object( - PluginHelper, - "get_current_system_version", - return_value=Version("3.0.0"), - ): - plan, message = PluginHelper._build_remote_plugin_install_plan( - pid="DemoPlugin", - meta={ - "release": True, - "version": "1.2.3", - "system_version": ">=9", - }, - release_version="1.2.3", - release_items=[{"version": "1.2.3"}], - ) + plan, message = build_plugin_release_install_plan( + plugin_id="DemoPlugin", + metadata={ + "release": True, + "version": "1.2.3", + "system_version": ">=9", + }, + release_version="1.2.3", + release_items=[{"version": "1.2.3"}], + current_version="3.0.0", + ) assert plan is None assert "MoviePilot 版本 >=9" in message diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index ec6e15019..28aff5349 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -8,7 +8,7 @@ import pytest from packaging.version import Version from watchfiles import Change -from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin.client import PluginMarketTransport from app.foundation.singleton import Singleton from app.runtime.events import Event, eventmanager from app.runtime.extensions.plugin.paths import PluginPathResolver @@ -24,8 +24,8 @@ from app.schemas.types import EventType, SystemConfigKey def plugin_manager(monkeypatch) -> Iterator[PluginManager]: """构造隔离的插件管理器实例,避免单例状态污染其它用例。""" system = get_plugin_system() - from app.adapters.external import market as market_module - original_runtime_setting = market_module.get_runtime_setting + from app.adapters.external.plugin import client as plugin_client_module + original_runtime_setting = plugin_client_module.get_runtime_setting class _SettingsStub(SimpleNamespace): """允许存量用例覆盖尚未显式声明的配置键。""" @@ -38,13 +38,12 @@ def plugin_manager(monkeypatch) -> Iterator[PluginManager]: REPO_GITHUB_HEADERS=original_runtime_setting("REPO_GITHUB_HEADERS"), PLUGIN_LOCAL_REPO_PATHS="", ) - monkeypatch.setattr(market_module, "settings", market_settings, raising=False) monkeypatch.setattr( - market_module, + plugin_client_module, "get_runtime_setting", lambda key, default=None: ( - getattr(market_module.settings, key) - if hasattr(market_module.settings, key) + getattr(market_settings, key) + if hasattr(market_settings, key) else original_runtime_setting(key, default) ), ) @@ -55,7 +54,7 @@ def plugin_manager(monkeypatch) -> Iterator[PluginManager]: candidate = system.local_candidate( kwargs["plugin_id"], package_version=kwargs.get("package_version"), - repo_path=PluginHelper.parse_local_repo_path(repo_url), + repo_path=PluginMarketTransport.parse_local_repo_path(repo_url), strict_system_version=False, ) if not candidate: @@ -102,10 +101,18 @@ def _build_local_plugin_repo(tmp_path: Path) -> tuple[Path, Path]: def _patch_plugin_runtime_settings(monkeypatch, settings) -> None: """以只读键值端口注入插件运行配置。""" - monkeypatch.setattr( + for target in ( "app.runtime.extensions.plugin_manager.get_runtime_setting", - lambda key: getattr(settings, key), - ) + "app.adapters.external.plugin.client.get_runtime_setting", + ): + monkeypatch.setattr( + target, + lambda key, default=None: getattr( + settings, + key, + "v2" if key == "VERSION_FLAG" else default, + ), + ) def _patch_package_runtime_settings(monkeypatch, settings) -> None: @@ -135,7 +142,6 @@ def _configure_local_watcher( VERSION_FLAG="v2", ) _patch_plugin_runtime_settings(monkeypatch, settings_stub) - monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) _patch_package_runtime_settings(monkeypatch, settings_stub) monkeypatch.setattr("app.runtime.extensions.plugin_manager.watch", lambda *_args, **_kwargs: iter([changes])) @@ -159,8 +165,8 @@ def _set_installed_plugins(monkeypatch, plugin_ids: list[str]) -> None: else None, ) monkeypatch.setattr( - "app.runtime.extensions.plugin_manager.get_plugin_storage", - lambda: storage, + "app.runtime.extensions.plugin.storage._plugin_storage", + storage, ) @@ -208,11 +214,11 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp", CONFIG_PATH=tmp_path / "config", + PLUGIN_LOCAL_REPO_PATHS=str(repo_path), ) _patch_plugin_runtime_settings(monkeypatch, settings_stub) _patch_package_runtime_settings(monkeypatch, settings_stub) - monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path)) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.10")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file) @@ -235,10 +241,13 @@ def test_local_plugin_candidate_keeps_system_version_gate_outside_dev( _patch_plugin_runtime_settings( monkeypatch, - SimpleNamespace(DEV=False, ROOT_PATH=tmp_path), + SimpleNamespace( + DEV=False, + ROOT_PATH=tmp_path, + PLUGIN_LOCAL_REPO_PATHS=str(repo_path), + ), ) - monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path)) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.10")) candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file) @@ -263,8 +272,7 @@ def test_local_plugin_sync_without_candidate_respects_system_version_gate( ) _patch_plugin_runtime_settings(monkeypatch, settings_stub) - monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.10")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) assert not plugin_manager._sync_local_plugin_if_installed("DemoPlugin") @@ -291,7 +299,7 @@ def test_local_federated_asset_batch_syncs_once_without_python_reload( }, ) _set_running_render_mode(plugin_manager, "vue", "dist/assets") - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() @@ -418,7 +426,6 @@ def test_local_federated_asset_reads_running_render_mode_for_each_batch( VERSION_FLAG="v2", ) _patch_plugin_runtime_settings(monkeypatch, settings_stub) - monkeypatch.setattr("app.adapters.external.market.settings", settings_stub) monkeypatch.setattr( "app.runtime.extensions.plugin_manager.watch", lambda *_args, **_kwargs: iter([ @@ -456,7 +463,7 @@ def test_local_federated_asset_respects_non_dev_compatibility_gate( dev=False, ) _set_running_render_mode(plugin_manager, "vue", "dist/assets") - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.10")) sync_spy = Mock() reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) @@ -520,7 +527,7 @@ def test_local_requirements_change_still_does_not_sync_or_reload( repo_path, {(Change.modified, str(requirements_file))}, ) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) sync_spy = Mock() reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) @@ -553,7 +560,7 @@ def test_local_pyproject_change_prompts_reinstall_without_sync_or_reload( repo_path, {(Change.modified, str(pyproject_file))}, ) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) sync_spy = Mock() reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) @@ -587,7 +594,7 @@ def test_local_inactive_requirements_change_is_debug_only( repo_path, {(Change.modified, str(requirements_file))}, ) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) sync_spy = Mock() reload_spy = Mock() monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy) @@ -625,7 +632,7 @@ def test_deleting_active_pyproject_prompts_for_requirements_takeover( repo_path, {(Change.deleted, str(pyproject_file))}, ) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) log = Mock() monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log) @@ -651,7 +658,7 @@ def test_deleting_only_active_requirements_prompts_reinstall( repo_path, {(Change.deleted, str(requirements_file))}, ) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) log = Mock() monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log) @@ -682,7 +689,7 @@ def test_deleting_inactive_requirements_is_debug_only( repo_path, {(Change.deleted, str(requirements_file))}, ) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) log = Mock() monkeypatch.setattr("app.runtime.extensions.plugin_manager.logger", log) @@ -705,7 +712,7 @@ def test_local_python_change_still_syncs_and_reloads_plugin( repo_path, {(Change.modified, str(source_file))}, ) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() @@ -734,7 +741,7 @@ def test_local_python_change_rejects_root_federated_path_and_still_reloads( {(Change.modified, str(source_file))}, ) _set_running_render_mode(plugin_manager, "vue", dist_path) - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() @@ -767,7 +774,7 @@ def test_local_python_and_federated_changes_share_one_batch_sync( }, ) _set_running_render_mode(plugin_manager, "vue", "dist/assets") - monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11")) + monkeypatch.setattr(PluginMarketTransport, "get_current_system_version", lambda: Version("2.13.11")) _set_installed_plugins(monkeypatch, ["DemoPlugin"]) sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed) reload_spy = Mock() diff --git a/tests/test_plugin_manager_governance.py b/tests/test_plugin_manager_governance.py new file mode 100644 index 000000000..91330ab6b --- /dev/null +++ b/tests/test_plugin_manager_governance.py @@ -0,0 +1,188 @@ +"""PluginManager 类型化 Runtime 与职责边界门禁。""" + +import ast +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] +MANAGER_PATH = PROJECT_ROOT / "app" / "runtime" / "extensions" / "plugin_manager.py" +RUNTIME_PATH = PROJECT_ROOT / "app" / "runtime" / "extensions" / "plugin" / "runtime.py" + + +def _parse(path: Path) -> ast.Module: + """解析治理门禁消费的 Python 模块。""" + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def _manager_class() -> ast.ClassDef: + """返回 canonical PluginManager 类节点。""" + return next( + node + for node in _parse(MANAGER_PATH).body + if isinstance(node, ast.ClassDef) and node.name == "PluginManager" + ) + + +def test_plugin_manager_only_consumes_injected_runtime_factory() -> None: + """Manager 构造器只能消费注入工厂,不得构造 Runtime 或任一职责 owner。""" + manager = _manager_class() + initializer = next( + node + for node in manager.body + if isinstance(node, ast.FunctionDef) and node.name == "__init__" + ) + calls = [ + ast.unparse(node.func) + for node in ast.walk(initializer) + if isinstance(node, ast.Call) + ] + manager_calls = { + ast.unparse(node.func) + for node in ast.walk(manager) + if isinstance(node, ast.Call) + } + forbidden = { + "PluginAccessPolicy", + "PluginCatalogFacade", + "PluginCloneService", + "PluginDependencyService", + "PluginLifecycle", + "PluginLoader", + "PluginMetadataMapper", + "PluginMonitorController", + "PluginPathResolver", + "PluginProjection", + "PluginRegistry", + "PluginSyncService", + "PluginToolCatalog", + } + + assert calls.count("_plugin_runtime_factory") == 1 + assert "build_plugin_runtime" not in calls + assert "PluginRuntimeEnvironment" not in calls + assert forbidden.isdisjoint(manager_calls) + + +def test_plugin_runtime_dependency_graph_lives_in_startup_composition() -> None: + """Runtime Environment 与 builder 只能由启动组合根连接。""" + startup_path = PROJECT_ROOT / "app" / "startup" / "initializers" / "plugins.py" + manager_imports = { + node.name + for node in ast.walk(_parse(MANAGER_PATH)) + if isinstance(node, ast.ImportFrom) + and node.module == "app.runtime.extensions.plugin.runtime" + for node in node.names + } + startup_calls = { + ast.unparse(node.func) + for node in ast.walk(_parse(startup_path)) + if isinstance(node, ast.Call) + } + + assert manager_imports == {"PluginRuntime"} + assert { + "PluginRuntimeEnvironment", + "build_plugin_runtime", + "configure_plugin_runtime_factory", + } <= startup_calls + + +def test_startup_injects_runtime_factory_before_manager_materialization() -> None: + """所有启动入口必须先发布 Runtime 工厂,再物化 PluginManager。""" + startup_path = PROJECT_ROOT / "app" / "startup" / "initializers" / "plugins.py" + functions = { + node.name: node + for node in _parse(startup_path).body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + service_calls = sorted( + (node.lineno, ast.unparse(node.func)) + for node in ast.walk(functions["configure_plugin_services"]) + if isinstance(node, ast.Call) + ) + sync_calls = sorted( + (node.lineno, ast.unparse(node.func)) + for node in ast.walk(functions["sync_plugins"]) + if isinstance(node, ast.Call) + ) + + assert service_calls.index( + next(item for item in service_calls if item[1] == "configure_plugin_runtime_factory") + ) < service_calls.index( + next(item for item in service_calls if item[1] == "get_plugin_manager") + ) + assert sync_calls.index( + next(item for item in sync_calls if item[1] == "configure_plugin_services") + ) < sync_calls.index( + next(item for item in sync_calls if item[1] == "PluginManager") + ) + + +def test_plugin_manager_does_not_reach_raw_system_adapters() -> None: + """兼容门面不得穿透市场、包或依赖适配器的原始属性。""" + tree = _parse(MANAGER_PATH) + imported_modules = { + node.module + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + } + raw_attributes = { + node.attr + for node in ast.walk(tree) + if isinstance(node, ast.Attribute) + and node.attr in {"market", "package", "dependency"} + } + + assert not any(module.startswith("app.adapters") for module in imported_modules) + assert raw_attributes == set() + + +def test_plugin_runtime_is_the_only_owner_aggregate() -> None: + """类型化 Runtime 必须集中列出唯一 owner,且包根不提供重复导出。""" + tree = _parse(RUNTIME_PATH) + runtime = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == "PluginRuntime" + ) + fields = { + node.target.id + for node in runtime.body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + } + package_root = RUNTIME_PATH.parent / "__init__.py" + + assert { + "catalog", + "dependencies", + "lifecycle", + "loader", + "monitor", + "projection", + "registry", + "sync", + "system", + } <= fields + assert len(_parse(package_root).body) == 1 + + +def test_dependency_classification_lives_in_dependency_owner() -> None: + """虚拟实例分类和状态写入不得回流 PluginManager。""" + manager = _manager_class() + methods = { + node.name: node + for node in manager.body + if isinstance(node, ast.FunctionDef) + } + for name, delegate in ( + ("classify_plugins", "self._plugin_dependencies.classify_plugins"), + ( + "apply_plugin_dependency_classification", + "self._plugin_dependencies.apply_classification", + ), + ): + calls = [ + ast.unparse(node.func) + for node in ast.walk(methods[name]) + if isinstance(node, ast.Call) + ] + assert calls == [delegate] diff --git a/tests/test_plugin_market_index_policy.py b/tests/test_plugin_market_index_policy.py index 086f63eff..d3f941850 100644 --- a/tests/test_plugin_market_index_policy.py +++ b/tests/test_plugin_market_index_policy.py @@ -4,8 +4,7 @@ from types import SimpleNamespace import pytest -from app.adapters.external.market import PluginHelper -from app.adapters.external.plugin.client import PluginMarketClient +from app.adapters.external.plugin.client import PluginMarketClient, PluginMarketTransport @pytest.mark.asyncio @@ -13,7 +12,7 @@ async def test_sync_and_async_plugin_indexes_share_request_and_result( monkeypatch, ) -> None: """同步与异步索引读取必须使用同一请求计划并返回相同三态结果。""" - helper = PluginHelper() + helper = PluginMarketTransport() repo_url = "https://github.com/policy-owner/policy-repository" response = SimpleNamespace( status_code=200, @@ -34,12 +33,12 @@ async def test_sync_and_async_plugin_indexes_share_request_and_result( monkeypatch.setattr( helper, - "_PluginHelper__request_with_fallback", + "_PluginMarketTransport__request_with_fallback", sync_request, ) monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginMarketTransport__async_request_with_fallback", async_request, ) @@ -63,7 +62,7 @@ async def test_sync_and_async_plugin_indexes_share_request_and_result( @pytest.mark.asyncio async def test_market_read_warms_source_inventory_cache(monkeypatch) -> None: """市场目录成功读取后,来源库存不得再次请求同一仓库代际。""" - helper = PluginHelper() + helper = PluginMarketTransport() repo_url = "https://github.com/policy-owner/shared-index-cache" requests = 0 @@ -77,7 +76,7 @@ async def test_market_read_warms_source_inventory_cache(monkeypatch) -> None: monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginMarketTransport__async_request_with_fallback", request, ) await helper.async_get_plugin_index_result.cache_clear() @@ -93,7 +92,7 @@ def test_sync_force_refresh_bypasses_index_cache( monkeypatch, ) -> None: """同步强刷必须绕过唯一索引缓存。""" - helper = PluginHelper() + helper = PluginMarketTransport() client = PluginMarketClient(helper) repo_url = "https://github.com/policy-owner/sync-force-refresh" version = "1.0.0" @@ -109,7 +108,7 @@ def test_sync_force_refresh_bypasses_index_cache( monkeypatch.setattr( helper, - "_PluginHelper__request_with_fallback", + "_PluginMarketTransport__request_with_fallback", request, ) helper.get_plugin_index_result.cache_clear() @@ -134,7 +133,7 @@ async def test_async_force_refresh_bypasses_index_cache( monkeypatch, ) -> None: """异步强刷必须绕过唯一索引缓存。""" - helper = PluginHelper() + helper = PluginMarketTransport() client = PluginMarketClient(helper) repo_url = "https://github.com/policy-owner/async-force-refresh" version = "1.0.0" @@ -150,7 +149,7 @@ async def test_async_force_refresh_bypasses_index_cache( monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginMarketTransport__async_request_with_fallback", request, ) await helper.async_get_plugin_index_result.cache_clear() @@ -173,7 +172,7 @@ async def test_async_force_refresh_bypasses_index_cache( @pytest.mark.asyncio async def test_absent_plugin_generation_is_cached(monkeypatch) -> None: """明确不存在的代际是稳定事实,后续来源检查不得重复请求。""" - helper = PluginHelper() + helper = PluginMarketTransport() repo_url = "https://github.com/policy-owner/absent-index-cache" requests = 0 @@ -184,7 +183,7 @@ async def test_absent_plugin_generation_is_cached(monkeypatch) -> None: monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginMarketTransport__async_request_with_fallback", request, ) await helper.async_get_plugin_index_result.cache_clear() @@ -201,7 +200,7 @@ async def test_index_cache_retains_multi_market_generation_working_set( monkeypatch, ) -> None: """数十个市场的多代索引不能因缓存容量不足立即重复出站。""" - helper = PluginHelper() + helper = PluginMarketTransport() requests = 0 async def request(_url: str, *, headers: dict): @@ -211,7 +210,7 @@ async def test_index_cache_retains_multi_market_generation_working_set( monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginMarketTransport__async_request_with_fallback", request, ) await helper.async_get_plugin_index_result.cache_clear() @@ -244,7 +243,7 @@ def test_plugin_index_response_preserves_status_contract( expected: dict | None, ) -> None: """统一响应解析必须保留 404 空索引、失败和有效字典的既有区别。""" - result = PluginHelper._resolve_plugin_index_response(status_code, content) + result = PluginMarketTransport()._resolve_plugin_index_response(status_code, content) assert result == expected @@ -263,13 +262,13 @@ def test_plugin_index_result_preserves_read_state( expected: dict | None, ) -> None: """只读入口以值和 None 区分真实索引与确定不存在。""" - helper = PluginHelper() + helper = PluginMarketTransport() repo_url = f"https://github.com/policy-owner/policy-repository-{status_code}" def request(_url: str, *, headers: dict): return SimpleNamespace(status_code=status_code, text=content) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request) + monkeypatch.setattr(helper, "_PluginMarketTransport__request_with_fallback", request) helper.get_plugin_index_result.cache_clear() result = helper.get_plugin_index_result(repo_url, "v3") @@ -291,12 +290,12 @@ def test_plugin_index_result_raises_for_unusable_reads( message: str, ) -> None: """不可判定读取必须抛错,由应用库存统一记录失败事实。""" - helper = PluginHelper() + helper = PluginMarketTransport() def request(_url: str, *, headers: dict): return SimpleNamespace(status_code=status_code, text=content) - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request) + monkeypatch.setattr(helper, "_PluginMarketTransport__request_with_fallback", request) helper.get_plugin_index_result.cache_clear() with pytest.raises(RuntimeError, match=message): @@ -309,14 +308,14 @@ def test_plugin_index_result_raises_for_unusable_reads( @pytest.mark.asyncio async def test_async_plugin_index_result_preserves_absent_state(monkeypatch) -> None: """异步只读入口也必须保留 404 不存在事实。""" - helper = PluginHelper() + helper = PluginMarketTransport() async def request(_url: str, *, headers: dict): return SimpleNamespace(status_code=404, text="404: Not Found") monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginMarketTransport__async_request_with_fallback", request, ) await helper.async_get_plugin_index_result.cache_clear() @@ -331,12 +330,12 @@ async def test_async_plugin_index_result_preserves_absent_state(monkeypatch) -> def test_plugin_index_result_propagates_adapter_exception(monkeypatch) -> None: """请求异常必须传播给应用库存统一转换为失败事实。""" - helper = PluginHelper() + helper = PluginMarketTransport() def request(_url: str, *, headers: dict): raise OSError("socket closed") - monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request) + monkeypatch.setattr(helper, "_PluginMarketTransport__request_with_fallback", request) helper.get_plugin_index_result.cache_clear() with pytest.raises(OSError, match="socket closed"): diff --git a/tests/test_plugin_market_request_policy.py b/tests/test_plugin_market_request_policy.py index 86ec3a3bb..e0750c138 100644 --- a/tests/test_plugin_market_request_policy.py +++ b/tests/test_plugin_market_request_policy.py @@ -4,8 +4,8 @@ from types import SimpleNamespace import pytest -from app.adapters.external import market -from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin import client +from app.adapters.external.plugin.client import PluginMarketTransport @pytest.mark.asyncio @@ -20,7 +20,7 @@ async def test_sync_and_async_github_requests_share_fallback_policy( PROXY=proxy, ) monkeypatch.setattr( - market, + client, "get_runtime_setting", lambda key, default=None: getattr(runtime_settings, key, default), ) @@ -56,15 +56,15 @@ async def test_sync_and_async_github_requests_share_fallback_policy( raise RuntimeError("next strategy") return response - monkeypatch.setattr(market, "RequestUtils", SyncRequest) - monkeypatch.setattr(market, "AsyncRequestUtils", AsyncRequest) + monkeypatch.setattr(client, "RequestUtils", SyncRequest) + monkeypatch.setattr(client, "AsyncRequestUtils", AsyncRequest) - sync_response = PluginHelper._PluginHelper__request_with_fallback( + sync_response = PluginMarketTransport._PluginMarketTransport__request_with_fallback( "https://api.example/resource", headers={"X-Test": "1"}, timeout=12, ) - async_response = await PluginHelper._PluginHelper__async_request_with_fallback( + async_response = await PluginMarketTransport._PluginMarketTransport__async_request_with_fallback( "https://api.example/resource", headers={"X-Test": "1"}, timeout=12, @@ -95,12 +95,12 @@ def test_github_api_request_policy_skips_content_mirror(monkeypatch) -> None: PROXY=None, ) monkeypatch.setattr( - market, + client, "get_runtime_setting", lambda key, default=None: getattr(runtime_settings, key, default), ) - strategies = PluginHelper._build_github_request_strategies( + strategies = PluginMarketTransport._build_github_request_strategies( url="https://api.github.com/repos/example/plugins/releases", is_api=True, ) diff --git a/tests/test_plugin_package_manager.py b/tests/test_plugin_package_manager.py index 11966d4f7..5efbf39ac 100644 --- a/tests/test_plugin_package_manager.py +++ b/tests/test_plugin_package_manager.py @@ -1,7 +1,7 @@ import shutil from pathlib import Path from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import pytest @@ -24,7 +24,7 @@ def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager: "app.adapters.system.plugin.package.capture_loaded_native_dependencies", LoadedNativeDependencySnapshot, ) - return PluginPackageManager(helper=Mock()) + return PluginPackageManager(source=Mock()) def test_checkpoint_rollback_restores_existing_package(monkeypatch, tmp_path): @@ -45,6 +45,177 @@ def test_checkpoint_rollback_restores_existing_package(monkeypatch, tmp_path): assert not checkpoint.transaction_dir.exists() +def test_checkpoint_uses_injected_plugin_root(monkeypatch, tmp_path): + """显式装配的插件根目录必须覆盖全局运行目录设置。""" + _manager(monkeypatch, tmp_path) + plugin_root = tmp_path / "custom-plugins" + plugin_dir = plugin_root / "demoplugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("custom", encoding="utf-8") + manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root) + + checkpoint = manager.checkpoint("DemoPlugin") + + assert checkpoint.plugin_dir == plugin_dir.resolve() + assert (checkpoint.transaction_dir / "package" / "__init__.py").read_text( + encoding="utf-8" + ) == "custom" + manager.commit(checkpoint) + + +def test_remove_plugin_uses_package_owner_path_boundary(tmp_path): + """物理卸载只删除注入根目录内的目标插件。""" + plugin_root = tmp_path / "plugins" + plugin_dir = plugin_root / "demoplugin" + plugin_dir.mkdir(parents=True) + (plugin_dir / "__init__.py").write_text("plugin", encoding="utf-8") + outside = tmp_path / "outside" + outside.mkdir() + manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root) + + assert manager.remove_plugin("DemoPlugin") is True + assert not plugin_dir.exists() + assert outside.exists() + assert manager.remove_plugin("DemoPlugin") is False + with pytest.raises(ValueError, match="非法插件ID"): + manager.remove_plugin("../outside") + + +@pytest.mark.parametrize( + ("remote_path", "package_version"), + [ + ("../escaped.py", None), + ("/tmp/escaped.py", None), + ("C:\\escaped.py", None), + ("plugins/other/file.py", None), + ("plugins.v2/demoplugin/../escaped.py", "v2"), + ], +) +def test_file_list_download_rejects_paths_outside_plugin_root( + monkeypatch, + tmp_path, + remote_path, + package_version, +): + """同步文件列表安装不得把远端路径写到当前插件目录之外。""" + plugin_root = tmp_path / "plugins" + manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root) + request = Mock() + monkeypatch.setattr( + manager, + "_PluginPackageManager__request_with_fallback", + request, + ) + + result = manager._PluginPackageManager__download_files( + "DemoPlugin", + [{"path": remote_path, "download_url": "https://example.invalid/file"}], + "owner/repo", + package_version, + ) + + assert result == (False, "插件文件路径无效") + request.assert_not_called() + assert not (tmp_path / "escaped.py").exists() + + +@pytest.mark.asyncio +async def test_async_file_list_download_rejects_path_outside_plugin_root( + monkeypatch, + tmp_path, +): + """异步文件列表安装复用同一受控路径边界。""" + manager = PluginPackageManager(source=Mock(), plugin_root=tmp_path / "plugins") + request = AsyncMock() + monkeypatch.setattr( + manager, + "_PluginPackageManager__async_request_with_fallback", + request, + ) + + result = await manager._PluginPackageManager__async_download_files( + "DemoPlugin", + [ + { + "path": "plugins.v2/demoplugin/../../escaped.py", + "download_url": "https://example.invalid/file", + } + ], + "owner/repo", + "v2", + ) + + assert result == (False, "插件文件路径无效") + request.assert_not_awaited() + assert not (tmp_path / "escaped.py").exists() + + +@pytest.mark.asyncio +async def test_file_list_download_rejects_traversal_directory_names( + monkeypatch, + tmp_path, +): + """目录项名称不得扩大后续市场查询到当前插件树之外。""" + manager = PluginPackageManager(source=Mock(), plugin_root=tmp_path / "plugins") + sync_query = Mock() + async_query = AsyncMock() + monkeypatch.setattr( + manager, + "_PluginPackageManager__get_file_list", + sync_query, + ) + monkeypatch.setattr( + manager, + "_PluginPackageManager__async_get_file_list", + async_query, + ) + item = {"name": "..", "download_url": None} + + assert manager._PluginPackageManager__download_files( + "DemoPlugin", [item], "owner/repo" + ) == (False, "插件目录路径无效") + assert await manager._PluginPackageManager__async_download_files( + "DemoPlugin", [item], "owner/repo" + ) == (False, "插件目录路径无效") + sync_query.assert_not_called() + async_query.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_file_list_download_maps_valid_paths_into_injected_plugin_root( + monkeypatch, + tmp_path, +): + """同步与异步文件列表都只写入显式装配的插件根目录。""" + plugin_root = tmp_path / "plugins" + response = SimpleNamespace(status_code=200, text="payload") + manager = PluginPackageManager(source=Mock(), plugin_root=plugin_root) + monkeypatch.setattr( + manager, + "_PluginPackageManager__request_with_fallback", + Mock(return_value=response), + ) + monkeypatch.setattr( + manager, + "_PluginPackageManager__async_request_with_fallback", + AsyncMock(return_value=response), + ) + item = { + "path": "plugins.v2/demoplugin/nested/file.py", + "download_url": "https://example.invalid/file", + } + + assert manager._PluginPackageManager__download_files( + "DemoPlugin", [item], "owner/repo", "v2" + ) == (True, "") + assert await manager._PluginPackageManager__async_download_files( + "DemoPlugin", [item], "owner/repo", "v2" + ) == (True, "") + assert (plugin_root / "demoplugin" / "nested" / "file.py").read_text( + encoding="utf-8" + ) == "payload" + + def test_checkpoint_does_not_scan_native_dependencies(monkeypatch, tmp_path): """普通插件文件快照不应枚举宿主全部原生发行包。""" manager = _manager(monkeypatch, tmp_path) diff --git a/tests/test_plugin_package_version_resolution.py b/tests/test_plugin_package_version_resolution.py index 75e6f087a..1dd4a20ad 100644 --- a/tests/test_plugin_package_version_resolution.py +++ b/tests/test_plugin_package_version_resolution.py @@ -4,8 +4,8 @@ from types import SimpleNamespace import pytest -from app.adapters.external import market -from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin import client as client_module +from app.adapters.external.plugin.client import PluginMarketTransport @pytest.mark.parametrize( @@ -25,12 +25,12 @@ def test_package_version_candidates_have_one_canonical_order( """显式版本、向后兼容版本和基础索引必须由一个有序事实源产生。""" runtime_settings = SimpleNamespace(VERSION_FLAG=configured_version) monkeypatch.setattr( - market, + client_module, "get_runtime_setting", lambda key, default=None: getattr(runtime_settings, key, default), ) - assert PluginHelper._package_version_candidates(requested_version) == expected + assert PluginMarketTransport._package_version_candidates(requested_version) == expected @pytest.mark.asyncio @@ -40,11 +40,11 @@ async def test_sync_and_async_package_resolution_visit_same_candidates( """同步与异步安装必须按相同顺序停止在首个兼容插件索引。""" runtime_settings = SimpleNamespace(VERSION_FLAG="v3") monkeypatch.setattr( - market, + client_module, "get_runtime_setting", lambda key, default=None: getattr(runtime_settings, key, default), ) - helper = PluginHelper.__new__(PluginHelper) + transport = PluginMarketTransport() indexes = { "v3": {}, "v2": {"CompatiblePlugin": {"version": "1.0.0"}}, @@ -66,14 +66,14 @@ async def test_sync_and_async_package_resolution_visit_same_candidates( async_candidates.append(package_version) return indexes[package_version] - monkeypatch.setattr(helper, "get_plugins", get_plugins) - monkeypatch.setattr(helper, "async_get_plugins", async_get_plugins) + monkeypatch.setattr(transport, "get_plugins", get_plugins) + monkeypatch.setattr(transport, "async_get_plugins", async_get_plugins) - sync_result = helper.get_plugin_package_version( + sync_result = transport.get_plugin_package_version( "CompatiblePlugin", "https://github.com/example/plugins", ) - async_result = await helper.async_get_plugin_package_version( + async_result = await transport.async_get_plugin_package_version( "CompatiblePlugin", "https://github.com/example/plugins", ) diff --git a/tests/test_plugin_release_page_policy.py b/tests/test_plugin_release_page_policy.py index c59c725ad..d938c582e 100644 --- a/tests/test_plugin_release_page_policy.py +++ b/tests/test_plugin_release_page_policy.py @@ -2,7 +2,7 @@ import pytest -from app.adapters.external.market import PluginHelper +from app.adapters.external.plugin.client import PluginMarketTransport class _PageResponse: @@ -36,7 +36,7 @@ def _release_payload(size: int, *, offset: int = 0) -> list[dict]: @pytest.mark.asyncio async def test_sync_and_async_release_pages_share_policy(monkeypatch) -> None: """同步与异步分页必须请求相同页并产生相同规范化仓库快照。""" - helper = PluginHelper() + helper = PluginMarketTransport() repo_url = "https://github.com/policy-owner/release-repository" responses = [ _PageResponse(_release_payload(100)), @@ -57,12 +57,12 @@ async def test_sync_and_async_release_pages_share_policy(monkeypatch) -> None: monkeypatch.setattr( helper, - "_PluginHelper__request_with_fallback", + "_PluginMarketTransport__request_with_fallback", sync_request, ) monkeypatch.setattr( helper, - "_PluginHelper__async_request_with_fallback", + "_PluginMarketTransport__async_request_with_fallback", async_request, ) @@ -102,7 +102,7 @@ def test_release_page_merge_preserves_stop_and_failure_contract( """统一分页解析必须区分继续、自然结束和整次仓库读取失败。""" releases: list[dict] = [] - result = PluginHelper._merge_plugin_release_page( + result = PluginMarketTransport()._merge_plugin_release_page( "https://github.com/policy-owner/release-repository", response, releases, diff --git a/tests/test_runtime_execution.py b/tests/test_runtime_execution.py index c4ae468bc..d2709d46a 100644 --- a/tests/test_runtime_execution.py +++ b/tests/test_runtime_execution.py @@ -1,7 +1,7 @@ """运行时同步 worker 的取消与容量合同回归。""" -import asyncio import ast +import asyncio import json import subprocess import sys @@ -11,14 +11,12 @@ from pathlib import Path import pytest from anyio.to_thread import current_default_thread_limiter -from app.adapters.external import market as market_adapter from app.adapters.system.plugin import package as plugin_package_adapter from app.runtime.execution import ( await_task_to_terminal, run_in_threadpool_to_completion, ) - PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -81,8 +79,7 @@ print(json.dumps(observed)) def test_plugin_file_adapters_share_runtime_completion_contract() -> None: - """市场与插件包适配器不得各自维护另一套线程取消实现。""" - assert market_adapter._await_thread_operation is run_in_threadpool_to_completion + """插件包文件适配器不得维护另一套线程取消实现。""" assert ( plugin_package_adapter._await_thread_operation is run_in_threadpool_to_completion