Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/plugins/core/runtime/entrypoint.py
T
insistence 4bd83bc7cd feat: 支持 v2 插件、独立前端和签名制品发布 (#130)
* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
2026-10-09 23:49:45 +08:00

245 lines
11 KiB
Python

import base64
import hashlib
import importlib.metadata
import importlib.util
import inspect
import platform
import sys
import sysconfig
from dataclasses import dataclass
from importlib.machinery import EXTENSION_SUFFIXES
from pathlib import Path
from types import ModuleType
from typing import Any
from packaging.tags import parse_tag, sys_tags
from packaging.utils import canonicalize_name
from packaging.version import Version
from plugins.core.discovery.scanner import DiscoveredPlugin
from plugins.core.sdk import PluginDefinition, PluginHostContext
@dataclass(frozen=True)
class PluginModuleLocation:
"""
静态定位结果,不执行包初始化或原生模块代码。
:param module_name: 插件模块完整名称
:param path: 模块文件的绝对路径
:param is_package: 模块是否为 Python 包
"""
module_name: str
path: Path
is_package: bool = False
class PluginEntrypointLoader:
"""
只加载当前插件目录下的模块,拒绝复用其他版本或其他路径的缓存。
"""
def __init__(self, plugin: DiscoveredPlugin) -> None:
"""
初始化显式入口加载器并确认插件使用 v2 清单。
:param plugin: 待加载的已发现插件
:return: None
"""
self.plugin = plugin
self.backend = plugin.manifest.backend
if not plugin.manifest.uses_entrypoint:
raise ValueError('显式入口加载器仅用于 manifestVersion 2')
@property
def module_root(self) -> Path:
"""
获取当前交付类型对应的模块根目录。
:return: Python 插件目录或原生发行包安装目录的绝对路径
"""
if self.plugin.manifest.runtime_kind == 'native':
return self._contained(self.plugin.backend_path / self.backend.native.module_root)
return self.plugin.backend_path.resolve()
def _contained(self, path: Path) -> Path:
"""
解析真实路径并确认其位于当前插件目录内。
:param path: 待校验的模块或资源路径
:return: 通过目录边界校验的绝对路径
"""
resolved = path.resolve()
if not resolved.is_relative_to(self.plugin.backend_path.resolve()):
raise ValueError(f'模块或资源路径越过插件根目录:{path}')
return resolved
def locate_module(self, module_name: str) -> PluginModuleLocation:
"""
仅检查磁盘文件,不调用可能执行父包代码的 find_spec。
:param module_name: 当前插件命名空间内的完整模块名
:return: 模块文件路径及包类型信息
"""
prefix = self.backend.module
if module_name != prefix and not module_name.startswith(f'{prefix}.'):
raise ValueError(f'入口或回调必须位于当前插件模块:{module_name}')
parts = module_name.removeprefix(prefix).lstrip('.').split('.') if module_name != prefix else []
root = self.module_root
if self.plugin.manifest.runtime_kind == 'native':
root = self._contained(root / prefix)
target = root.joinpath(*parts)
candidates: list[tuple[Path, bool]] = []
if parts or self.plugin.manifest.runtime_kind == 'native':
candidates.extend((target.with_name(target.name + suffix), False) for suffix in EXTENSION_SUFFIXES)
candidates.append((target.with_suffix('.py'), False))
candidates.append((target / '__init__.py', True))
for path, is_package in candidates:
if path.is_file():
return PluginModuleLocation(module_name, self._contained(path), is_package)
raise ImportError(f'插件模块不存在:{module_name}')
def check_entrypoint(self) -> PluginModuleLocation:
"""
执行静态来源和原生平台校验,不执行入口。
:return: 通过来源及平台校验的入口模块位置
"""
module_name = self.backend.entrypoint.split(':', 1)[0]
location = self.locate_module(module_name)
if self.plugin.manifest.runtime_kind == 'native':
if not any(location.path.name.endswith(suffix) for suffix in EXTENSION_SUFFIXES):
raise ValueError('native entrypoint 必须位于原生 Python 扩展模块')
self._check_native_distribution(location)
return location
def _check_native_distribution(self, location: PluginModuleLocation) -> None:
"""
校验原生发行包版本、平台标签及入口文件摘要。
:param location: 已定位的原生入口模块
:return: None
"""
if platform.python_implementation() != 'CPython' or sysconfig.get_config_var('Py_GIL_DISABLED'):
raise ValueError('当前 native 运行时仅支持带 GIL 的 CPython')
expected = canonicalize_name(self.backend.native.distribution)
matches = [
dist
for dist in importlib.metadata.distributions(path=[str(self.module_root)])
if canonicalize_name(dist.metadata.get('Name', '')) == expected
]
if len(matches) != 1:
raise ValueError('native.moduleRoot 必须包含唯一匹配的已安装 wheel 元数据')
dist = matches[0]
if Version(dist.version) != Version(self.plugin.manifest.version):
raise ValueError('原生发行包版本必须与插件版本一致')
wheel_metadata = dist.read_text('WHEEL') or ''
tags = set()
for line in wheel_metadata.splitlines():
if line.startswith('Tag: '):
tags.update(parse_tag(line.removeprefix('Tag: ').strip()))
if not any(tag.abi != 'none' and tag.platform != 'any' for tag in tags.intersection(sys_tags())):
raise ValueError('原生 wheel 平台、Python 或 ABI 与当前解释器不兼容')
entries = [entry for entry in dist.files or [] if Path(dist.locate_file(entry)).resolve() == location.path]
if len(entries) != 1 or entries[0].hash is None or entries[0].hash.mode != 'sha256':
raise ValueError('原生入口必须包含在发行包 RECORD 中并声明 SHA256')
digest = hashlib.sha256(location.path.read_bytes()).digest()
actual = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
if actual != entries[0].hash.value:
raise ValueError('原生入口 SHA256 与发行包 RECORD 不一致')
def import_module(self, module_name: str) -> ModuleType:
"""
按绑定路径依次加载包和模块,失败时移除本次新增的插件模块。
:param module_name: 当前插件命名空间内的完整模块名
:return: 已从绑定路径加载的模块对象
"""
self.locate_module(module_name)
before = set(sys.modules)
try:
prefix = self.backend.module
names = [prefix]
suffix = module_name.removeprefix(prefix).lstrip('.')
if suffix:
for part in suffix.split('.'):
names.append(f'{names[-1]}.{part}')
for name in names:
self._import_one(name)
return sys.modules[module_name]
except BaseException:
for name in set(sys.modules) - before:
if name == self.backend.module or name.startswith(f'{self.backend.module}.'):
failed_module = sys.modules.pop(name, None)
parent_name, _, child_name = name.rpartition('.')
parent = sys.modules.get(parent_name)
if parent is not None and getattr(parent, child_name, None) is failed_module:
delattr(parent, child_name)
raise
def _import_one(self, module_name: str) -> ModuleType:
"""
校验模块缓存来源,或从已定位的文件加载单个模块。
:param module_name: 当前插件命名空间内的完整模块名
:return: 来源路径一致的模块对象
"""
location = self.locate_module(module_name)
cached = sys.modules.get(module_name)
if cached is not None:
origin = getattr(cached, '__file__', None)
if not origin or Path(origin).resolve() != location.path:
raise ImportError(f'插件模块已从其他路径加载,必须重启进程:{module_name}')
return cached
spec = importlib.util.spec_from_file_location(
module_name,
location.path,
submodule_search_locations=[str(location.path.parent)] if location.is_package else None,
)
if spec is None or spec.loader is None:
raise ImportError(f'无法创建插件模块加载器:{module_name}')
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
parent_name, _, child_name = module_name.rpartition('.')
parent = sys.modules.get(parent_name)
if parent is not None:
setattr(parent, child_name, module)
return module
def load_callable(self, callable_path: str) -> Any:
"""
v2 回调使用固定参数协议,不依赖原生函数 inspect.signature。
:param callable_path: 采用模块路径与函数名冒号分隔格式的入口或回调路径
:return: 通过可调用性校验的入口或回调对象
"""
module_name, callable_name = callable_path.split(':', 1)
module = self.import_module(module_name)
callback = getattr(module, callable_name, None)
if not callable(callback):
raise TypeError(f'插件入口或回调不可调用:{callable_path}')
return callback
def load(self, host: PluginHostContext) -> PluginDefinition:
"""
加载入口并验证能力对象;工厂必须同步、快速且无资源副作用。
:param host: 与当前插件身份及资源目录绑定的宿主上下文
:return: 通过协议校验的插件能力定义
"""
self.check_entrypoint()
if host.plugin_id != self.plugin.manifest.id or host.resource_root != self.plugin.backend_path.resolve():
raise ValueError('HostContext 与待加载插件不匹配')
factory = self.load_callable(self.backend.entrypoint)
definition = factory(host)
if inspect.isawaitable(definition):
if inspect.iscoroutine(definition):
definition.close()
raise TypeError('插件入口工厂必须同步返回 PluginDefinition,资源初始化请使用生命周期')
if not isinstance(definition, PluginDefinition):
raise TypeError('插件入口必须返回 PluginDefinition')
definition.validate(self.plugin.manifest.integration_kind)
return definition