Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py
T
insistence 2a055ba648 feat: 新增插件系统 (#112)
* feat: 初始化插件系统

* refactor: 收口插件系统运行时重构

* perf: 优化插件系统类型提示

* fix&perf: 修复和优化插件系统

* fix: 修复gitignore规则误忽略插件文件的问题

* fix: 修复运行时插件根路径算错的问题

* fix: 加强插件发现和路由注册的防护措施

* revert: 回滚定时任务白名单

* fix: 移除未使用的应用路由注册探测

* revert: 恢复部分代码

* perf: 优化插件系统

* docs: 新增插件开发文档

* perf: 优化插件管理模块

* perf: 提升插件系统核心能力

* refactor: 重构生命周期 step runner

* fix: 修复lint错误

* test: 清理测试用例

* test: 调整测试目录名称

* fix: 修复前后端目录硬编码的问题

* fix: 修复插件系统安全性缺口

* refactor: 重新设计插件生命周期 Migration 事务与回滚

* perf: 优化插件系统边界问题

* refactor: 重构当前插件系统的依赖体系设计

* perf: 优化代码

* perf: 优化代码

* fix: 修复代码合并问题

* fix: 修复bug

* perf: 优化代码

* perf&fix: 优化代码和修复bug

* docs: 优化文档格式

* feat: 适配Vue2版本

* docs: 更新README文档

* fix: 修复ruff lint错误

* chore: 更新后端依赖文件
2026-07-28 20:35:18 +08:00

646 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import re
import sys
from dataclasses import dataclass
from importlib import metadata
from pathlib import Path
from typing import Literal
from packaging.requirements import InvalidRequirement
from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT
from plugins.core.manifest.schema import PluginManifest
from plugins.core.validation.python_requirements import PythonRequirementParser
from plugins.core.validation.versioning import PluginVersionComparator, PluginVersionConstraintMatcher
DependencyKind = Literal['python', 'npm', 'npmDev']
DependencyStatus = Literal['checked', 'skipped']
DEPENDENCY_PATTERN = re.compile(r'^\s*([A-Za-z0-9_.@/\-]+)\s*([<>=!~^]{1,2})?\s*([A-Za-z0-9_.+\-!*]+)?\s*$')
PYTHON_PACKAGE_SEPARATOR_PATTERN = re.compile(r'[-_.]+')
PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX = '插件启动依赖检查失败:'
@dataclass(frozen=True)
class DependencyCheckItem:
"""
单条依赖检查结果。
"""
kind: DependencyKind
requirement: str
name: str
installed: bool
version_satisfied: bool
installed_version: str | None
required_version: str | None
message: str
status: DependencyStatus = 'checked'
declared_version: str | None = None
@property
def ok(self) -> bool:
"""
判断依赖检查是否通过。
:return: 是否通过
"""
return self.status == 'skipped' or (self.installed and self.version_satisfied)
@dataclass(frozen=True)
class DependencyCheckResult:
"""
插件依赖检查结果。
"""
plugin_id: str
items: list[DependencyCheckItem]
@property
def ok(self) -> bool:
"""
判断插件依赖检查是否整体通过。
:return: 是否通过
"""
return all(item.ok for item in self.items)
@property
def missing_items(self) -> list[DependencyCheckItem]:
"""
获取缺失依赖列表。
:return: 缺失依赖列表
"""
return [item for item in self.items if item.status != 'skipped' and not item.installed]
@property
def unsatisfied_items(self) -> list[DependencyCheckItem]:
"""
获取版本不满足依赖列表。
:return: 版本不满足依赖列表
"""
return [
item for item in self.items if item.status != 'skipped' and item.installed and not item.version_satisfied
]
@dataclass(frozen=True)
class DependencyInstallPlanItem:
"""
依赖安装计划项。
"""
kind: DependencyKind
requirement: str
name: str
command: list[str]
workdir: str
reason: str
@dataclass(frozen=True)
class DependencyInstallPlan:
"""
插件依赖安装计划。
"""
plugin_id: str
items: list[DependencyInstallPlanItem]
@property
def has_actions(self) -> bool:
"""
判断安装计划是否包含待执行动作。
:return: 是否包含待执行动作
"""
return bool(self.items)
@dataclass(frozen=True)
class ParsedDependency:
"""
已解析依赖声明。
"""
name: str
operator: str | None
version: str | None
@property
def required_version(self) -> str | None:
"""
获取完整版本约束。
:return: 完整版本约束
"""
if not self.operator or not self.version:
return None
return f'{self.operator}{self.version}'
class DependencyRequirementParser:
"""
依赖声明解析器。
使用 Strategy 模式为 Python 与 npm 提供共享的轻量依赖声明解析能力。
注意:该解析器基于正则,不支持 PEP 508 的逗号范围(``openai>=2,<3``)和 marker。
Python 依赖请使用 :class:`PythonRequirementParser`。
"""
@classmethod
def parse(cls, requirement: str) -> ParsedDependency:
"""
解析依赖声明。
:param requirement: 依赖声明
:return: 已解析依赖声明
"""
matched_requirement = DEPENDENCY_PATTERN.match(requirement)
if not matched_requirement:
return ParsedDependency(name=requirement.strip(), operator=None, version=None)
name, operator, version = matched_requirement.groups()
return ParsedDependency(name=name, operator=operator, version=version)
class VersionConstraintMatcher:
"""
版本约束匹配器。
"""
@classmethod
def is_satisfied(cls, installed_version: str | None, parsed_dependency: ParsedDependency) -> bool:
"""
判断已安装版本是否满足依赖声明。
:param installed_version: 已安装版本
:param parsed_dependency: 已解析依赖声明
:return: 是否满足
"""
if not installed_version:
return False
if not parsed_dependency.operator or not parsed_dependency.version:
return True
if parsed_dependency.operator in {'^', '~'}:
return PluginVersionConstraintMatcher.match_compatible(
installed_version,
parsed_dependency.version,
parsed_dependency.operator,
)
comparison = PluginVersionConstraintMatcher.is_satisfied(
installed_version,
parsed_dependency.operator,
parsed_dependency.version,
)
if not comparison and not cls._can_compare(installed_version, parsed_dependency.version):
return cls._match_text_version(installed_version, parsed_dependency)
return comparison
@staticmethod
def _match_text_version(installed_version: str, parsed_dependency: ParsedDependency) -> bool:
"""
匹配非纯数字版本声明。
:param installed_version: 已安装版本
:param parsed_dependency: 已解析依赖声明
:return: 是否满足
"""
if parsed_dependency.operator in {'==', '='}:
return installed_version == parsed_dependency.version
if parsed_dependency.operator == '!=':
return installed_version != parsed_dependency.version
return True
@staticmethod
def _can_compare(installed_version: str, required_version: str) -> bool:
"""
判断两个版本是否可按插件版本规则比较。
:param installed_version: 已安装版本
:param required_version: 约束版本
:return: 是否可比较
"""
return PluginVersionComparator.compare(installed_version, required_version) is not None
class PythonDependencyInspector:
"""
Python 依赖检查器。
"""
def __init__(self, installed_packages: dict[str, str] | None = None) -> None:
"""
初始化 Python 依赖检查器。
:param installed_packages: 已安装 Python 包版本映射
"""
self._installed_packages_overridden = installed_packages is not None
self.installed_packages = (
self._normalize_installed_packages(installed_packages)
if installed_packages is not None
else self._load_installed_packages()
)
def refresh(self) -> None:
"""
刷新当前 Python 环境的已安装包快照。
显式传入 ``installed_packages`` 的检查器保持固定快照,便于测试和离线检查;
从运行环境创建的检查器会重新读取 distribution 元数据。
:return: None
"""
if self._installed_packages_overridden:
return
self.installed_packages = self._load_installed_packages()
def check(self, requirements: list[str]) -> list[DependencyCheckItem]:
"""
检查 Python 依赖声明。
:param requirements: Python 依赖声明列表
:return: 依赖检查结果列表
"""
return [self._check_requirement(requirement) for requirement in requirements]
def _check_requirement(self, requirement: str) -> DependencyCheckItem:
"""
检查单条 Python 依赖声明。
含 marker 的声明先评估 marker,不适用当前环境时返回 ``status='skipped'``,
避免合法的条件依赖阻断插件启动。
:param requirement: Python 依赖声明
:return: 依赖检查结果
"""
try:
parsed = PythonRequirementParser.parse(requirement)
except InvalidRequirement as exc:
return DependencyCheckItem(
kind='python',
requirement=requirement,
name=requirement.strip(),
installed=False,
version_satisfied=False,
installed_version=None,
required_version=None,
message=f'Python 依赖声明无效:{requirement},{exc}',
)
if not parsed.is_marker_applicable():
return DependencyCheckItem(
kind='python',
requirement=requirement,
name=parsed.name,
installed=False,
version_satisfied=True,
installed_version=None,
required_version=parsed.required_version,
message=f'Python 依赖 marker 不适用当前环境,已跳过:{parsed.name}',
status='skipped',
)
installed_version = self.installed_packages.get(self._normalize_package_name(parsed.name))
installed = installed_version is not None
version_satisfied = parsed.is_version_satisfied(installed_version)
message = self._build_message(
parsed.name,
installed,
version_satisfied,
installed_version,
parsed.required_version,
)
return DependencyCheckItem(
kind='python',
requirement=requirement,
name=parsed.name,
installed=installed,
version_satisfied=version_satisfied,
installed_version=installed_version,
required_version=parsed.required_version,
message=message,
)
@staticmethod
def _load_installed_packages() -> dict[str, str]:
"""
读取当前 Python 环境已安装包版本。
:return: 已安装包版本映射
"""
return {
PythonDependencyInspector._normalize_package_name(distribution.metadata['Name']): distribution.version
for distribution in metadata.distributions()
}
@classmethod
def _normalize_installed_packages(cls, installed_packages: dict[str, str]) -> dict[str, str]:
"""
归一化已安装 Python 包名称映射。
:param installed_packages: 已安装 Python 包版本映射
:return: 归一化包名后的版本映射
"""
return {cls._normalize_package_name(name): version for name, version in installed_packages.items()}
@staticmethod
def _normalize_package_name(name: str) -> str:
"""
按 PEP 503 规则归一化 Python distribution 名称。
:param name: Python 包名
:return: 归一化包名
"""
return PYTHON_PACKAGE_SEPARATOR_PATTERN.sub('-', name).lower()
@staticmethod
def _build_message(
name: str,
installed: bool,
version_satisfied: bool,
installed_version: str | None,
required_version: str | None,
) -> str:
"""
构建 Python 依赖检查消息。
:param name: 包名
:param installed: 是否已安装
:param version_satisfied: 版本是否满足
:param installed_version: 已安装版本
:param required_version: 版本约束
:return: 检查消息
"""
if not installed:
return f'Python 依赖未安装:{name}'
if not version_satisfied:
return f'Python 依赖版本不满足:{name} installed={installed_version} required={required_version}'
return f'Python 依赖已满足:{name}'
class NpmDependencyInspector:
"""
npm 依赖检查器。
"""
def __init__(
self, frontend_root: Path | str | None = None, installed_packages: dict[str, str] | None = None
) -> None:
"""
初始化 npm 依赖检查器。
:param frontend_root: 前端项目根目录
:param installed_packages: 已声明 npm 包版本映射
"""
self.frontend_root = (
Path(frontend_root) if frontend_root else Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_dir())
)
self.installed_packages = (
installed_packages if installed_packages is not None else self._load_installed_packages()
)
def check(self, requirements: list[str], *, dev: bool = False) -> list[DependencyCheckItem]:
"""
检查 npm 依赖声明。
:param requirements: npm 依赖声明列表
:param dev: 是否为 npm 开发依赖
:return: 依赖检查结果列表
"""
return [self._check_requirement(requirement, dev=dev) for requirement in requirements]
def skip(self, requirements: list[str], *, dev: bool = False) -> list[DependencyCheckItem]:
"""
跳过 npm 依赖实际检查。
:param requirements: npm 依赖声明列表
:param dev: 是否为 npm 开发依赖
:return: 跳过检查结果列表
"""
return [self._skip_requirement(requirement, dev=dev) for requirement in requirements]
def _skip_requirement(self, requirement: str, *, dev: bool = False) -> DependencyCheckItem:
"""
构建单条 npm 依赖跳过结果。
:param requirement: npm 依赖声明
:param dev: 是否为 npm 开发依赖
:return: 依赖跳过结果
"""
parsed_dependency = DependencyRequirementParser.parse(requirement)
return DependencyCheckItem(
kind='npmDev' if dev else 'npm',
requirement=requirement,
name=parsed_dependency.name,
installed=False,
version_satisfied=True,
installed_version=None,
required_version=parsed_dependency.required_version,
message='当前为已构建前端环境,前端依赖需在构建前安装。',
status='skipped',
)
def _check_requirement(self, requirement: str, *, dev: bool = False) -> DependencyCheckItem:
"""
检查单条 npm 依赖声明。
:param requirement: npm 依赖声明
:param dev: 是否为 npm 开发依赖
:return: 依赖检查结果
"""
parsed_dependency = DependencyRequirementParser.parse(requirement)
installed_version = self.installed_packages.get(parsed_dependency.name)
version_satisfied = VersionConstraintMatcher.is_satisfied(
self._normalize_npm_version(installed_version), parsed_dependency
)
installed = installed_version is not None
return DependencyCheckItem(
kind='npmDev' if dev else 'npm',
requirement=requirement,
name=parsed_dependency.name,
installed=installed,
version_satisfied=version_satisfied,
installed_version=installed_version,
required_version=parsed_dependency.required_version,
message=self._build_message(parsed_dependency, installed, version_satisfied, installed_version),
declared_version=installed_version,
)
def _load_installed_packages(self) -> dict[str, str]:
"""
读取前端 package.json 中声明的 npm 包版本。
:return: npm 包版本映射
"""
package_json_path = self.frontend_root / 'package.json'
if not package_json_path.is_file():
return {}
package_json = json.loads(package_json_path.read_text(encoding='utf-8'))
dependencies = package_json.get('dependencies', {})
dev_dependencies = package_json.get('devDependencies', {})
return {**dependencies, **dev_dependencies}
@staticmethod
def _normalize_npm_version(version: str | None) -> str | None:
"""
归一化 package.json 中的 npm 版本声明。
:param version: npm 版本声明
:return: 归一化版本
"""
if not version:
return None
return version.lstrip('^~>=<')
@staticmethod
def _build_message(
parsed_dependency: ParsedDependency,
installed: bool,
version_satisfied: bool,
installed_version: str | None,
) -> str:
"""
构建 npm 依赖检查消息。
:param parsed_dependency: 已解析依赖声明
:param installed: 是否已声明
:param version_satisfied: 版本是否满足
:param installed_version: 已声明版本
:return: 检查消息
"""
if not installed:
return f'npm 依赖未声明:{parsed_dependency.name}'
if not version_satisfied:
return f'npm 依赖版本不满足:{parsed_dependency.name} declared={installed_version} required={parsed_dependency.required_version}'
return f'npm 依赖已满足:{parsed_dependency.name}'
class PluginDependencyChecker:
"""
插件依赖检查器。
"""
def __init__(
self,
python_inspector: PythonDependencyInspector | None = None,
npm_inspector: NpmDependencyInspector | None = None,
frontend_mode: Literal['dev', 'built'] = 'dev',
) -> None:
"""
初始化插件依赖检查器。
:param python_inspector: Python 依赖检查器
:param npm_inspector: npm 依赖检查器
"""
self.python_inspector = python_inspector or PythonDependencyInspector()
self.npm_inspector = npm_inspector or NpmDependencyInspector()
self.frontend_mode = frontend_mode
def check_manifest(self, manifest: PluginManifest) -> DependencyCheckResult:
"""
检查插件清单依赖。
:param manifest: 插件清单
:return: 插件依赖检查结果
"""
items = []
items.extend(self.python_inspector.check(manifest.dependencies.python))
if self.frontend_mode == 'built':
items.extend(self.npm_inspector.skip(manifest.dependencies.npm))
items.extend(self.npm_inspector.skip(manifest.dependencies.npm_dev, dev=True))
else:
items.extend(self.npm_inspector.check(manifest.dependencies.npm))
items.extend(self.npm_inspector.check(manifest.dependencies.npm_dev, dev=True))
return DependencyCheckResult(plugin_id=manifest.id, items=items)
class PluginDependencyInstallPlanner:
"""
插件依赖安装计划生成器。
使用 Planner 模式将依赖检查结果转换为可审计、可 dry-run 的安装计划。
"""
def __init__(self, python_executable: str | None = None, frontend_root: Path | str | None = None) -> None:
"""
初始化插件依赖安装计划生成器。
:param python_executable: Python 可执行文件路径
:param frontend_root: 前端工程根目录
"""
self.python_executable = python_executable or sys.executable
self.frontend_root = (
Path(frontend_root) if frontend_root else Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_dir())
)
def build_plan(self, dependency_result: DependencyCheckResult) -> DependencyInstallPlan:
"""
构建依赖安装计划。
:param dependency_result: 依赖检查结果
:return: 依赖安装计划
"""
items = [
self._build_plan_item(item)
for item in dependency_result.items
if item.status != 'skipped' and (not item.installed or not item.version_satisfied)
]
return DependencyInstallPlan(plugin_id=dependency_result.plugin_id, items=items)
def _build_plan_item(self, item: DependencyCheckItem) -> DependencyInstallPlanItem:
"""
构建单个依赖安装计划项。
:param item: 依赖检查项
:return: 依赖安装计划项
"""
if item.kind == 'python':
return DependencyInstallPlanItem(
kind=item.kind,
requirement=item.requirement,
name=item.name,
command=[self.python_executable, '-m', 'pip', 'install', item.requirement],
workdir=str(Path.cwd()),
reason=item.message,
)
command = ['npm', 'install', self._build_npm_install_target(item)]
if item.kind == 'npmDev':
command.insert(2, '--save-dev')
return DependencyInstallPlanItem(
kind=item.kind,
requirement=item.requirement,
name=item.name,
command=command,
workdir=str(self.frontend_root),
reason=item.message,
)
@staticmethod
def _build_npm_install_target(item: DependencyCheckItem) -> str:
"""
构建 npm install 目标声明。
:param item: 依赖检查项
:return: npm install 目标
"""
parsed_dependency = DependencyRequirementParser.parse(item.requirement)
if not parsed_dependency.operator or not parsed_dependency.version:
return parsed_dependency.name
if parsed_dependency.operator in {'==', '='}:
return f'{parsed_dependency.name}@{parsed_dependency.version}'
return f'{parsed_dependency.name}@{parsed_dependency.required_version}'