mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 06:39:33 +08:00
fix(plugin): v3t 下不再隐藏不兼容插件,改为提示不支持 (#6760)
* feat(plugin): surface v3t incompatibility instead of hiding plugins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(architecture): refresh dependency baseline after plugin gate split Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(plugin): keep runtime-incompatible plugins out of startup sync Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(plugin): annotate runtime compatibility ports without bare dict Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
app/adapters/external/market.py
vendored
5
app/adapters/external/market.py
vendored
@@ -128,6 +128,11 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
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)
|
||||
check_plugin_runtime_compatibility = staticmethod(PluginMarketTransport.check_plugin_runtime_compatibility)
|
||||
annotate_plugin_runtime_compatibility = staticmethod(
|
||||
PluginMarketTransport.annotate_plugin_runtime_compatibility
|
||||
)
|
||||
check_plugin_install_compatibility = staticmethod(PluginMarketTransport.check_plugin_install_compatibility)
|
||||
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)
|
||||
|
||||
83
app/adapters/external/plugin/client.py
vendored
83
app/adapters/external/plugin/client.py
vendored
@@ -22,6 +22,7 @@ from requests import Response
|
||||
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
|
||||
from app.domain.plugin import (
|
||||
build_local_plugin_source,
|
||||
check_plugin_runtime_compatibility,
|
||||
check_plugin_system_version,
|
||||
is_local_plugin_source,
|
||||
is_physical_plugin_id,
|
||||
@@ -306,8 +307,6 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
"""
|
||||
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')
|
||||
@@ -336,8 +335,6 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
"""
|
||||
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
|
||||
@@ -415,6 +412,48 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
f"插件要求 MoviePilot 版本 {raw_specifier},当前版本 {get_app_version()} 不满足,已拒绝安装"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_plugin_runtime_compatibility(
|
||||
plugin_info: Optional[PluginPayload],
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
检查插件声明与当前解释器运行时是否兼容,返回兼容状态和用户可读原因。
|
||||
"""
|
||||
return check_plugin_runtime_compatibility(
|
||||
plugin_info,
|
||||
free_threaded=is_free_threaded_runtime(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def annotate_plugin_runtime_compatibility(
|
||||
cls, plugin_info: PluginPayload
|
||||
) -> PluginPayload:
|
||||
"""
|
||||
为插件 package 元数据补充运行时兼容状态,便于市场展示和安装流程复用。
|
||||
"""
|
||||
if not isinstance(plugin_info, dict):
|
||||
return plugin_info
|
||||
|
||||
compatible, message = cls.check_plugin_runtime_compatibility(plugin_info)
|
||||
plugin_info["runtime_compatible"] = compatible
|
||||
plugin_info["runtime_message"] = message
|
||||
return plugin_info
|
||||
|
||||
@classmethod
|
||||
def check_plugin_install_compatibility(
|
||||
cls, plugin_info: Optional[PluginPayload]
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
安装准入的唯一兼容判据:先判运行时,再判主程序版本。
|
||||
|
||||
运行时不兼容先于版本判断返回,否则 v3t 上会把"该插件不支持 free-threaded"
|
||||
报成一条与版本有关的提示。
|
||||
"""
|
||||
compatible, message = cls.check_plugin_runtime_compatibility(plugin_info)
|
||||
if not compatible:
|
||||
return compatible, message
|
||||
return cls.check_plugin_system_version(plugin_info)
|
||||
|
||||
@classmethod
|
||||
def annotate_plugin_system_version(
|
||||
cls, plugin_info: PluginPayload
|
||||
@@ -531,6 +570,7 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
package_version or None,
|
||||
)
|
||||
self.annotate_plugin_system_version(candidate)
|
||||
self.annotate_plugin_runtime_compatibility(candidate)
|
||||
candidate_version = str(candidate.get("version") or "0")
|
||||
|
||||
existing = candidates.get(pid)
|
||||
@@ -606,7 +646,13 @@ class PluginMarketTransport(metaclass=WeakSingleton):
|
||||
f"插件索引条目不兼容 {get_runtime_setting('VERSION_FLAG')}"
|
||||
)
|
||||
self.annotate_plugin_system_version(candidate)
|
||||
if strict_system_version and candidate.get("system_version_compatible") is False:
|
||||
self.annotate_plugin_runtime_compatibility(candidate)
|
||||
# 运行时不兼容与代际、版本不兼容一样要投影为不可安装候选:
|
||||
# 本地热同步只认 compatible 位,漏掉这一位会让每次源码变更都发起一次必败安装。
|
||||
if candidate.get("runtime_compatible") is False:
|
||||
candidate["compatible"] = False
|
||||
candidate["skip_reason"] = candidate.get("runtime_message")
|
||||
elif 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:
|
||||
@@ -1904,6 +1950,17 @@ class PluginMarketClient:
|
||||
plugin_info["system_version_message"] = message
|
||||
return plugin_info
|
||||
|
||||
@staticmethod
|
||||
def annotate_runtime_compatibility(plugin_info: PluginPayload) -> PluginPayload:
|
||||
"""补充插件声明的运行时兼容状态。"""
|
||||
compatible, message = check_plugin_runtime_compatibility(
|
||||
plugin_info,
|
||||
free_threaded=is_free_threaded_runtime(),
|
||||
)
|
||||
plugin_info["runtime_compatible"] = compatible
|
||||
plugin_info["runtime_message"] = message
|
||||
return plugin_info
|
||||
|
||||
@staticmethod
|
||||
def is_package_compatible(
|
||||
plugin_info: PluginPayload,
|
||||
@@ -1914,7 +1971,6 @@ class PluginMarketClient:
|
||||
plugin_info,
|
||||
package_version,
|
||||
current_generation=get_runtime_setting('VERSION_FLAG'),
|
||||
free_threaded=is_free_threaded_runtime(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1988,6 +2044,21 @@ class PluginPackageSourceClient:
|
||||
plugin_info, current_version=get_app_version()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_plugin_install_compatibility(
|
||||
plugin_info: PluginPayload,
|
||||
) -> tuple[bool, str]:
|
||||
"""安装准入的唯一兼容判据:先判运行时,再判宿主版本。"""
|
||||
compatible, message = check_plugin_runtime_compatibility(
|
||||
plugin_info,
|
||||
free_threaded=is_free_threaded_runtime(),
|
||||
)
|
||||
if not compatible:
|
||||
return compatible, message
|
||||
return check_plugin_system_version(
|
||||
plugin_info, current_version=get_app_version()
|
||||
)
|
||||
|
||||
def get_plugin_package_version(
|
||||
self,
|
||||
plugin_id: str,
|
||||
|
||||
@@ -101,6 +101,11 @@ class PluginPackageSourcePort(Protocol):
|
||||
) -> tuple[bool, str]:
|
||||
"""校验插件声明的宿主版本约束。"""
|
||||
|
||||
def check_plugin_install_compatibility(
|
||||
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]:
|
||||
@@ -729,6 +734,12 @@ class PluginPackageManager:
|
||||
"""委托外部来源客户端校验宿主版本约束。"""
|
||||
return self._require_source().check_plugin_system_version(plugin_info)
|
||||
|
||||
def check_plugin_install_compatibility(
|
||||
self, plugin_info: dict[str, Any]
|
||||
) -> tuple[bool, str]:
|
||||
"""委托外部来源客户端校验运行时与宿主版本约束。"""
|
||||
return self._require_source().check_plugin_install_compatibility(plugin_info)
|
||||
|
||||
def get_plugin_package_version(
|
||||
self, plugin_id: str, repo_url: str, package_version: Optional[str]
|
||||
) -> Optional[str]:
|
||||
@@ -1134,9 +1145,10 @@ class PluginPackageManager:
|
||||
)
|
||||
if not candidate:
|
||||
return False, f"未找到本地插件:{pid}"
|
||||
compatible, message = self.check_plugin_system_version(candidate)
|
||||
# 本地插件包走同一条准入判据:运行时不兼容要如实报,不能落到版本提示上
|
||||
compatible, message = self.check_plugin_install_compatibility(candidate)
|
||||
if not compatible:
|
||||
logger.debug(f"{pid} 本地插件系统版本兼容性检查失败:{message}")
|
||||
logger.debug(f"{pid} 本地插件安装兼容性检查失败:{message}")
|
||||
return False, message
|
||||
|
||||
raw_source_path = candidate.get("path")
|
||||
|
||||
@@ -251,6 +251,7 @@ async def runtime_status(
|
||||
_SchemaPluginRuntimeStatus.SYNC_FAILED,
|
||||
_SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY,
|
||||
_SchemaPluginRuntimeStatus.LOAD_FAILED,
|
||||
_SchemaPluginRuntimeStatus.INCOMPATIBLE_RUNTIME,
|
||||
}
|
||||
return _SchemaPluginRuntimeSummary(
|
||||
ready=not plugin_manager.is_plugin_settling(),
|
||||
@@ -394,14 +395,14 @@ def reload_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_s
|
||||
return _SchemaResponse(success=False, message=str(error))
|
||||
if runtime_status is _SchemaPluginRuntimeStatus.ACTIVE:
|
||||
return _SchemaResponse(success=True)
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=(
|
||||
"未通过用户认证,请查看日志"
|
||||
if runtime_status is _SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY
|
||||
else "插件加载失败,请查看插件日志"
|
||||
),
|
||||
)
|
||||
if runtime_status is _SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY:
|
||||
message = "未通过用户认证,请查看日志"
|
||||
elif runtime_status is _SchemaPluginRuntimeStatus.INCOMPATIBLE_RUNTIME:
|
||||
# 与插件卡片保持同一说法:重载不会改变载荷自身的运行时声明
|
||||
message = "插件声明不支持当前运行环境,请查看日志"
|
||||
else:
|
||||
message = "插件加载失败,请查看插件日志"
|
||||
return _SchemaResponse(success=False, message=message)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -21,7 +21,6 @@ from app.application.plugin.source import (
|
||||
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")
|
||||
PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]]
|
||||
@@ -358,7 +357,6 @@ def _market_candidates(
|
||||
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")
|
||||
@@ -399,7 +397,6 @@ def _local_candidate(
|
||||
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")
|
||||
|
||||
@@ -88,12 +88,15 @@ def _merge_plugin_source_metadata(plugin: Any, source_plugin: Any) -> Any:
|
||||
if repo_url:
|
||||
setattr(plugin, "repo_url", repo_url)
|
||||
|
||||
# 运行时兼容状态与版本兼容状态同属安装准入依据,已安装卡片据此禁用更新入口
|
||||
for attr in (
|
||||
"has_update",
|
||||
"release",
|
||||
"system_version",
|
||||
"system_version_compatible",
|
||||
"system_version_message",
|
||||
"runtime_compatible",
|
||||
"runtime_message",
|
||||
):
|
||||
value = getattr(source_plugin, attr, None)
|
||||
if value is not None:
|
||||
|
||||
@@ -220,6 +220,11 @@ def _merge_market_metadata(plugin: Plugin, market_plugin: Plugin) -> Plugin:
|
||||
market_plugin.system_version_message
|
||||
or plugin.system_version_message
|
||||
),
|
||||
# 版本历史弹窗的"更新到最新版"同样按运行时兼容状态禁用并说明原因
|
||||
"runtime_compatible": market_plugin.runtime_compatible,
|
||||
"runtime_message": (
|
||||
market_plugin.runtime_message or plugin.runtime_message
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,8 @@ from packaging.version import InvalidVersion, Version
|
||||
|
||||
LOCAL_PLUGIN_SOURCE_PREFIX = "local://"
|
||||
PLUGIN_SYSTEM_VERSION_FIELD = "system_version"
|
||||
# free-threaded 运行时(v3t)专用的插件声明字段;缺省视为兼容,只有显式 false 才拒绝
|
||||
PLUGIN_FREE_THREADED_FIELD = "v3t"
|
||||
_PHYSICAL_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,127}$")
|
||||
PLUGIN_GENERATION_COMPATIBILITY: dict[str, tuple[str, ...]] = {
|
||||
"v3": ("v2",),
|
||||
@@ -158,13 +160,16 @@ def is_plugin_generation_compatible(
|
||||
package_generation: str | None,
|
||||
*,
|
||||
current_generation: str | None,
|
||||
free_threaded: bool,
|
||||
) -> bool:
|
||||
"""按当前代际、兼容代际和基础索引规则判断一个市场条目。"""
|
||||
"""按当前代际、兼容代际和基础索引规则判断一个市场条目。
|
||||
|
||||
只回答代际问题。运行时(free-threaded)兼容性由
|
||||
:func:`check_plugin_runtime_compatibility` 单独判断并标注给前端,
|
||||
不在这里把条目从目录里剔除——用户需要看到插件和不可用的原因,
|
||||
而不是插件凭空消失。
|
||||
"""
|
||||
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:
|
||||
@@ -187,6 +192,29 @@ def is_plugin_generation_compatible(
|
||||
)
|
||||
|
||||
|
||||
def check_plugin_runtime_compatibility(
|
||||
plugin_info: object,
|
||||
*,
|
||||
free_threaded: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""检查插件声明与当前解释器运行时是否兼容,返回兼容状态和用户可读原因。
|
||||
|
||||
公开可调用:市场标注、安装准入和运行目录加载共用这一条判据,避免三处各写一份
|
||||
规则后出现"市场说能装、准入放行、加载又跳过"的分裂。当前只有 free-threaded
|
||||
一个维度,未声明该字段的插件一律保持兼容。
|
||||
:param plugin_info: 插件 package 条目
|
||||
:param free_threaded: 当前是否运行在 free-threaded 解释器上
|
||||
"""
|
||||
if not isinstance(plugin_info, Mapping):
|
||||
return True, ""
|
||||
if free_threaded and plugin_info.get(PLUGIN_FREE_THREADED_FIELD) is False:
|
||||
return False, (
|
||||
"插件声明不支持 free-threaded 运行时(v3t),"
|
||||
"请改用同版本标准 V3 镜像安装"
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def check_plugin_system_version(
|
||||
plugin_info: object,
|
||||
*,
|
||||
|
||||
@@ -71,6 +71,7 @@ class PluginLifecycle:
|
||||
enable_events: Callable[[Any], None],
|
||||
disable_events: Callable[[Any], None],
|
||||
runtime_status_writer: Callable[[str, PluginRuntimeStatus], None],
|
||||
runtime_compatible: Callable[[str], bool],
|
||||
database: Callable[[], PluginDatabase],
|
||||
log: Any,
|
||||
event_sender: Callable[..., Any],
|
||||
@@ -89,6 +90,7 @@ class PluginLifecycle:
|
||||
self._enable_events = enable_events
|
||||
self._disable_events = disable_events
|
||||
self._runtime_status_writer = runtime_status_writer
|
||||
self._runtime_compatible = runtime_compatible
|
||||
self._database = database
|
||||
self._logger = log
|
||||
self._event_sender = event_sender
|
||||
@@ -177,7 +179,13 @@ class PluginLifecycle:
|
||||
for result_id in results
|
||||
):
|
||||
self._remove_classification(plugin_id)
|
||||
status = PluginRuntimeStatus.LOAD_FAILED
|
||||
# 装载结果为空有两种原因:载荷声明与当前运行时不兼容被加载器跳过,
|
||||
# 或者真的导入失败。前者在卡片上要说明不支持,不能报成加载失败。
|
||||
status = (
|
||||
PluginRuntimeStatus.LOAD_FAILED
|
||||
if self._runtime_compatible(plugin_id)
|
||||
else PluginRuntimeStatus.INCOMPATIBLE_RUNTIME
|
||||
)
|
||||
self._runtime_status_writer(plugin_id, status)
|
||||
results[plugin_id] = status
|
||||
self._clear_tools()
|
||||
|
||||
@@ -14,12 +14,13 @@ from typing import Any, Optional
|
||||
|
||||
from app.foundation.environment import is_free_threaded_runtime
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.schemas.plugin import PluginInstance
|
||||
from app.schemas.plugin import PluginInstance, PluginRuntimeStatus
|
||||
|
||||
|
||||
PluginImportPreparer = Callable[..., None]
|
||||
PluginImportScanner = Callable[..., None]
|
||||
PluginValidator = Callable[[Any], bool]
|
||||
PluginRuntimeStatusWriter = Callable[[str, PluginRuntimeStatus], None]
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
@@ -34,11 +35,13 @@ class PluginLoader:
|
||||
import_preparer: PluginImportPreparer,
|
||||
import_scanner: PluginImportScanner,
|
||||
log: Any,
|
||||
runtime_status_writer: Optional[PluginRuntimeStatusWriter] = None,
|
||||
) -> None:
|
||||
"""保存插件目录、导入前置能力和日志端口。"""
|
||||
"""保存插件目录、导入前置能力、状态回写端口和日志端口。"""
|
||||
self._plugins_root = plugins_root
|
||||
self._import_preparer = import_preparer
|
||||
self._import_scanner = import_scanner
|
||||
self._runtime_status_writer = runtime_status_writer
|
||||
self._logger = log
|
||||
|
||||
def load(
|
||||
@@ -57,6 +60,12 @@ class PluginLoader:
|
||||
if plugin_id
|
||||
else [item.lower() for item in installed_plugins]
|
||||
)
|
||||
# 运行目录名统一小写,而卡片按调用方传入的原始 ID 读状态,回写前要还原大小写
|
||||
installed_ids = {
|
||||
item.lower(): item
|
||||
for item in ([plugin_id] if plugin_id else installed_plugins)
|
||||
if item
|
||||
}
|
||||
if not targets:
|
||||
self._logger.debug("没有需要加载的插件")
|
||||
return []
|
||||
@@ -80,6 +89,9 @@ class PluginLoader:
|
||||
self._logger.warning(
|
||||
f"跳过插件 {plugin_dir.name}:声明与当前运行时不兼容"
|
||||
)
|
||||
self._mark_incompatible_runtime(
|
||||
installed_ids.get(plugin_dir.name, plugin_dir.name)
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -127,6 +139,7 @@ class PluginLoader:
|
||||
self._logger.warning(
|
||||
f"跳过虚拟插件实例 {instance.instance_id}:声明与当前运行时不兼容"
|
||||
)
|
||||
self._mark_incompatible_runtime(instance.instance_id)
|
||||
return []
|
||||
|
||||
module_name = f"app.plugins.{instance.instance_id.lower()}"
|
||||
@@ -174,6 +187,29 @@ class PluginLoader:
|
||||
)
|
||||
return []
|
||||
|
||||
def is_runtime_compatible(self, plugin_id: str) -> bool:
|
||||
"""按插件 ID 判断运行目录载荷是否兼容当前运行时。
|
||||
|
||||
公开可调用:生命周期在装载结果为空时要区分"加载失败"和"运行时不兼容",
|
||||
两者在卡片上是完全不同的提示。
|
||||
:param plugin_id: 物理插件 ID,大小写不敏感
|
||||
"""
|
||||
return self._is_runtime_compatible(self._plugins_root / plugin_id.lower())
|
||||
|
||||
def _mark_incompatible_runtime(self, plugin_id: str) -> None:
|
||||
"""把运行时不兼容记成插件卡片可见的状态。
|
||||
|
||||
按 ID 定向装载时生命周期的空结果分支也会落同一个状态;这里补的是全量装载
|
||||
(``plugin_id`` 为空)那条路径——它不逐个遍历目标,缺了这一笔,用户看到的
|
||||
就是一张既不运行也不解释的占位卡片。
|
||||
"""
|
||||
if not self._runtime_status_writer:
|
||||
return
|
||||
self._runtime_status_writer(
|
||||
plugin_id,
|
||||
PluginRuntimeStatus.INCOMPATIBLE_RUNTIME,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_runtime_compatible(plugin_dir: Path) -> bool:
|
||||
"""按载荷自身 package 声明执行运行时兼容门禁,缺失声明时保持兼容。"""
|
||||
|
||||
@@ -18,6 +18,7 @@ class PluginMetadataMapper:
|
||||
plugin_instance: Callable[[str], Optional[Any]],
|
||||
plugin_class: Callable[[str], Optional[Any]],
|
||||
annotate_system_version: Callable[[dict], dict],
|
||||
annotate_runtime_compatibility: Callable[[dict[str, Any]], dict[str, Any]],
|
||||
is_package_compatible: Callable[[dict, str], bool],
|
||||
auth_checker: Callable[[Plugin, dict], bool],
|
||||
version_compare: Callable[[str, str, str], bool],
|
||||
@@ -27,6 +28,7 @@ class PluginMetadataMapper:
|
||||
self._plugin_instance = plugin_instance
|
||||
self._plugin_class = plugin_class
|
||||
self._annotate_system_version = annotate_system_version
|
||||
self._annotate_runtime_compatibility = annotate_runtime_compatibility
|
||||
self._is_package_compatible = is_package_compatible
|
||||
self._auth_checker = auth_checker
|
||||
self._version_compare = version_compare
|
||||
@@ -44,7 +46,9 @@ class PluginMetadataMapper:
|
||||
"""映射一个插件索引条目,不兼容或无权限时返回空。"""
|
||||
if not isinstance(plugin_info, dict):
|
||||
return None
|
||||
info = self._annotate_system_version(plugin_info.copy())
|
||||
info = self._annotate_runtime_compatibility(
|
||||
self._annotate_system_version(plugin_info.copy())
|
||||
)
|
||||
if not self._is_package_compatible(info, package_version or ""):
|
||||
return None
|
||||
|
||||
@@ -67,6 +71,10 @@ class PluginMetadataMapper:
|
||||
if info.get("system_version_compatible") is False:
|
||||
plugin.system_version_compatible = False
|
||||
plugin.system_version_message = info.get("system_version_message")
|
||||
# 运行时不兼容不再把条目从目录里剔除:卡片照常显示,由前端据此禁用安装并说明原因
|
||||
if info.get("runtime_compatible") is False:
|
||||
plugin.runtime_compatible = False
|
||||
plugin.runtime_message = info.get("runtime_message")
|
||||
|
||||
plugin.state = self._state(plugin_id, instance)
|
||||
plugin.has_page = bool(
|
||||
|
||||
@@ -162,6 +162,9 @@ def build_plugin_runtime(
|
||||
plugins_root=environment.plugins_root,
|
||||
import_preparer=environment.import_preparer,
|
||||
import_scanner=environment.import_scanner,
|
||||
# 运行时不兼容的插件不进入 lifecycle 遍历,状态只能由加载器自己落记,
|
||||
# 否则启动期全量加载会把它们跳过得无声无息
|
||||
runtime_status_writer=registry.set_runtime_status,
|
||||
log=environment.logger,
|
||||
)
|
||||
tools = PluginToolCatalog(max_attempts=tool_build_max_attempts)
|
||||
@@ -207,6 +210,12 @@ def build_plugin_runtime(
|
||||
plugins.extend(loader.load_instance(instance, validator))
|
||||
return plugins
|
||||
|
||||
def runtime_compatible(plugin_id: str) -> bool:
|
||||
"""按分身归一到源插件目录后判断运行时兼容性。"""
|
||||
instance = instances.get(plugin_id)
|
||||
source_id = instance.source_plugin_id if instance else plugin_id
|
||||
return loader.is_runtime_compatible(source_id)
|
||||
|
||||
lifecycle = PluginLifecycle(
|
||||
classes=registry.classes,
|
||||
running=registry.running,
|
||||
@@ -221,6 +230,7 @@ def build_plugin_runtime(
|
||||
enable_events=eventmanager.enable_event_handler,
|
||||
disable_events=eventmanager.disable_event_handler,
|
||||
runtime_status_writer=registry.set_runtime_status,
|
||||
runtime_compatible=runtime_compatible,
|
||||
database=environment.database,
|
||||
log=environment.logger,
|
||||
event_sender=eventmanager.send_event,
|
||||
@@ -233,6 +243,9 @@ def build_plugin_runtime(
|
||||
annotate_system_version=lambda info: environment.system().annotate_system_version(
|
||||
info
|
||||
),
|
||||
annotate_runtime_compatibility=lambda info: (
|
||||
environment.system().annotate_runtime_compatibility(info)
|
||||
),
|
||||
is_package_compatible=lambda info, version: environment.system().is_package_compatible(
|
||||
info,
|
||||
version,
|
||||
|
||||
@@ -97,7 +97,14 @@ class PluginSyncService:
|
||||
if getattr(plugin, "id", None)
|
||||
}
|
||||
)
|
||||
candidates = list(candidates_by_id.values())
|
||||
# 运行时声明不兼容的插件装不上:安装准入必然拒绝,同步失败状态会覆盖加载器写下的
|
||||
# 不兼容状态,恢复集合还会把整个启动同步判为未完成,连带跳过依赖恢复与调度器初始化。
|
||||
# 恢复分支同样排除——重装一个当前运行时装不上的插件不会有别的结果。
|
||||
candidates = [
|
||||
plugin
|
||||
for plugin in candidates_by_id.values()
|
||||
if plugin.runtime_compatible is not False
|
||||
]
|
||||
recovery_ids = {
|
||||
plugin.id.lower()
|
||||
for plugin in candidates
|
||||
|
||||
@@ -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:
|
||||
@@ -59,6 +59,15 @@ class PluginSystemServices:
|
||||
"""补充插件条目的主程序版本兼容信息。"""
|
||||
return self.market.annotate_system_version(plugin_info)
|
||||
|
||||
def annotate_runtime_compatibility(
|
||||
self, plugin_info: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""补充插件条目的运行时(free-threaded)兼容信息。"""
|
||||
return cast(
|
||||
dict[str, Any],
|
||||
self.market.annotate_runtime_compatibility(plugin_info),
|
||||
)
|
||||
|
||||
def is_package_compatible(self, plugin_info: dict, package_version: str) -> bool:
|
||||
"""判断插件条目是否兼容指定代际。"""
|
||||
return self.market.is_package_compatible(plugin_info, package_version)
|
||||
|
||||
@@ -22,6 +22,8 @@ class PluginRuntimeStatus(str, _Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED_BY_POLICY = "blocked_by_policy"
|
||||
LOAD_FAILED = "load_failed"
|
||||
# 插件自身声明与当前解释器运行时不兼容(当前只有 free-threaded 一个维度)
|
||||
INCOMPATIBLE_RUNTIME = "incompatible_runtime"
|
||||
|
||||
|
||||
class PluginSourceBindingStatus(str, _Enum):
|
||||
@@ -204,6 +206,10 @@ class Plugin(BaseModel):
|
||||
system_version_message: Optional[str] = None
|
||||
# 主系统版本限定范围
|
||||
system_version: Optional[str] = None
|
||||
# 插件声明与当前解释器运行时是否兼容;free-threaded(v3t)下显式声明不支持时为 False
|
||||
runtime_compatible: Optional[bool] = True
|
||||
# 运行时不兼容的用户可读原因
|
||||
runtime_message: Optional[str] = None
|
||||
# 是否声明支持通过 GitHub Release 资产安装
|
||||
release: Optional[bool] = False
|
||||
# 是否本地
|
||||
|
||||
@@ -588,7 +588,7 @@ def configure_plugin_services() -> None:
|
||||
inventory=load_inventory,
|
||||
identity=persistence.get_identity,
|
||||
candidate_compatibility=lambda candidate: (
|
||||
market_transport.check_plugin_system_version(candidate.dto)
|
||||
market_transport.check_plugin_install_compatibility(candidate.dto)
|
||||
),
|
||||
executor=command,
|
||||
clock=lambda: datetime.now(timezone.utc),
|
||||
|
||||
@@ -761,7 +761,7 @@ flowchart LR
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1044 |
|
||||
| 内部导入边 | 8,867 |
|
||||
| 内部导入边 | 8,865 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,867 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,865 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
|
||||
@@ -113,6 +113,23 @@ Alembic revision 文件的旧程序;依赖自愈成功后再清理旧代际备
|
||||
不得通过 `PYTHON_GIL=0` 或等效方式强制绕过扩展的 GIL 声明。扩展一旦使进程退化为 GIL 模式,
|
||||
应保留可观测性并修复或替换依赖;无法及时处理时回退标准 V3。
|
||||
|
||||
## 5.1 插件运行时声明与可见性
|
||||
|
||||
插件在 package 索引中用 `v3t: false` 声明不支持 free-threaded 运行时,缺省视为兼容,不要求生态
|
||||
全量标注。V3t 下该声明只改变可安装性和提示,不改变可见性:
|
||||
|
||||
- 市场目录保留条目并标注 `runtime_compatible`、`runtime_message`,前端据此禁用安装并说明原因。
|
||||
过滤掉条目会让插件在 V3t 上凭空消失,用户既看不到插件也看不到原因。
|
||||
- 安装准入统一经 `check_plugin_install_compatibility()` 判定,运行时判据先于宿主版本判据返回,
|
||||
在线安装与本地插件包共用这一条。前端禁用只是体验层,服务端必须独立拒绝。
|
||||
- 运行目录加载仍然跳过声明不兼容的载荷,并把插件状态写为 `incompatible_runtime`,
|
||||
由插件卡片显示不支持。按 ID 定向装载由生命周期的空结果分支落状态,全量装载不逐个遍历
|
||||
目标,由加载器自己落记。
|
||||
- 启动同步把运行时不兼容的插件排除在候选之外。它们装不上,进入同步只会得到一次必然失败的
|
||||
安装,并用同步失败状态覆盖不兼容状态;恢复集合还会据此把整个启动同步判为未完成。
|
||||
|
||||
该声明是人工标注,覆盖率天然滞后于真实依赖状况,不作为 V3t 依赖兼容性的唯一判据。
|
||||
|
||||
## 6. 当前 A/B 基线与决策
|
||||
|
||||
以下数据来自同一源码 revision、相同资源限制的 arm64 候选镜像,用于确定当前架构和依赖策略。后续
|
||||
|
||||
@@ -1084,8 +1084,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8867,
|
||||
"edge_sha256": "a109b2df878fb90a70b0cfd2a358eb291f460fe4a742c7d652820b7c203cd327",
|
||||
"edge_count": 8865,
|
||||
"edge_sha256": "6641acd38c2aec65ba3f527ce822ef687ed693bc8109c38b375bbd89499cdd68",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -3796,8 +3796,6 @@
|
||||
"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.management -> app.application",
|
||||
"app.application.plugin.management -> app.application.commands",
|
||||
"app.application.plugin.management -> app.application.configuration",
|
||||
|
||||
@@ -82,15 +82,12 @@ def test_only_v3_compatible_entries_are_candidates() -> None:
|
||||
assert not inventory.candidates_for("Undeclared")
|
||||
|
||||
|
||||
def test_free_threaded_runtime_excludes_explicit_v3t_false(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""V3t 只拒绝明确声明不支持的插件,未声明仍保持兼容。"""
|
||||
monkeypatch.setattr(
|
||||
"app.application.plugin.inventory.is_free_threaded_runtime",
|
||||
lambda: True,
|
||||
)
|
||||
def test_free_threaded_runtime_keeps_explicit_v3t_false_candidates() -> None:
|
||||
"""V3t 下声明不支持的插件仍进入候选,由安装准入负责拒绝并说明原因。
|
||||
|
||||
市场过滤会让插件在 v3t 上凭空消失,用户既看不到插件也看不到原因;
|
||||
条目保留后前端才能在卡片上显示不支持。
|
||||
"""
|
||||
inventory = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {
|
||||
"Allowed": {"version": "1.0.0"},
|
||||
@@ -99,7 +96,8 @@ def test_free_threaded_runtime_excludes_explicit_v3t_false(
|
||||
).load([THIRD_PARTY_MARKET])
|
||||
|
||||
assert {candidate.plugin_id for candidate in inventory.online_candidates} == {
|
||||
"Allowed"
|
||||
"Allowed",
|
||||
"Rejected",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ def test_metadata_mapper_preserves_package_generation_without_serializing_it():
|
||||
plugin_instance=lambda _plugin_id: None,
|
||||
plugin_class=lambda _plugin_id: None,
|
||||
annotate_system_version=lambda info: info,
|
||||
annotate_runtime_compatibility=lambda info: info,
|
||||
is_package_compatible=lambda _info, _version: True,
|
||||
auth_checker=lambda _plugin, _source: True,
|
||||
version_compare=lambda _left, _operator, _right: False,
|
||||
|
||||
@@ -105,6 +105,7 @@ def _build_lifecycle(**overrides: Any) -> PluginLifecycle:
|
||||
enable_events=lambda _plugin: None,
|
||||
disable_events=lambda _plugin: None,
|
||||
runtime_status_writer=lambda _plugin_id, _status: None,
|
||||
runtime_compatible=lambda _plugin_id: True,
|
||||
database=lambda: PluginDatabase(),
|
||||
log=logging.getLogger(__name__),
|
||||
event_sender=lambda *_args, **_kwargs: None,
|
||||
|
||||
@@ -1084,7 +1084,10 @@ class TestPluginHelper:
|
||||
self,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""V3t 只把 package 中明确的 v3t:false 视为运行时不兼容。"""
|
||||
"""V3t 只把 package 中明确的 v3t:false 视为运行时不兼容。
|
||||
|
||||
代际判断保持通过,条目照常进入市场目录;不兼容只体现在运行时判据和标注上。
|
||||
"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: True)
|
||||
@@ -1096,9 +1099,50 @@ class TestPluginHelper:
|
||||
|
||||
assert PluginHelper.is_package_plugin_compatible({}, "v3")
|
||||
assert PluginHelper.is_package_plugin_compatible({"v3t": True}, "v3")
|
||||
assert not PluginHelper.is_package_plugin_compatible(
|
||||
{"v3t": False}, "v3"
|
||||
assert PluginHelper.is_package_plugin_compatible({"v3t": False}, "v3")
|
||||
|
||||
assert PluginHelper.check_plugin_runtime_compatibility({"v3t": True})[0]
|
||||
compatible, message = PluginHelper.check_plugin_runtime_compatibility(
|
||||
{"v3t": False}
|
||||
)
|
||||
assert not compatible
|
||||
assert "v3t" in message
|
||||
|
||||
annotated = PluginHelper.annotate_plugin_runtime_compatibility(
|
||||
{"v3t": False}
|
||||
)
|
||||
assert annotated["runtime_compatible"] is False
|
||||
assert annotated["runtime_message"]
|
||||
|
||||
def test_install_compatibility_reports_runtime_before_system_version(
|
||||
self,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""安装准入在 v3t 上先报运行时不兼容,不把它说成版本问题。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: True)
|
||||
|
||||
compatible, message = PluginHelper.check_plugin_install_compatibility(
|
||||
{"v3t": False, "system_version": ">=99.0.0"}
|
||||
)
|
||||
|
||||
assert not compatible
|
||||
assert "free-threaded" in message
|
||||
|
||||
def test_install_compatibility_falls_back_to_system_version(
|
||||
self,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""标准运行时下安装准入仍然只看主程序版本范围。"""
|
||||
from app.adapters.external.market import PluginHelper
|
||||
|
||||
monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: False)
|
||||
|
||||
assert PluginHelper.check_plugin_install_compatibility({"v3t": False})[0]
|
||||
assert not PluginHelper.check_plugin_install_compatibility(
|
||||
{"system_version": ">=99.0.0"}
|
||||
)[0]
|
||||
|
||||
def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
|
||||
"""
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.adapters.system.plugin.package import (
|
||||
from app.domain.plugin import (
|
||||
build_local_plugin_source,
|
||||
build_plugin_release_install_plan,
|
||||
check_plugin_runtime_compatibility,
|
||||
check_plugin_system_version,
|
||||
compatible_plugin_generations,
|
||||
is_plugin_generation_compatible,
|
||||
@@ -179,7 +180,8 @@ def test_generation_candidate_policy_is_stable_and_deduplicated() -> None:
|
||||
({"v2": True}, None, True),
|
||||
({"v3": False, "v2": True}, None, False),
|
||||
({"v3": False}, "v3", False),
|
||||
({"v3t": False}, "v3", False),
|
||||
# 运行时声明不参与代际判断:v3t 由 check_plugin_runtime_compatibility 单独判定
|
||||
({"v3t": False}, "v3", True),
|
||||
],
|
||||
)
|
||||
def test_generation_policy_has_one_explicit_false_rule(
|
||||
@@ -187,15 +189,39 @@ def test_generation_policy_has_one_explicit_false_rule(
|
||||
generation: str | None,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""当前代际显式 false 和自由线程显式 false 都不得被专用索引绕过。"""
|
||||
"""当前代际显式 false 不得被专用索引绕过,运行时声明不改变代际结论。"""
|
||||
assert is_plugin_generation_compatible(
|
||||
metadata,
|
||||
generation,
|
||||
current_generation="v3",
|
||||
free_threaded=True,
|
||||
) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("metadata", "free_threaded", "compatible"),
|
||||
[
|
||||
(object(), True, True),
|
||||
({}, True, True),
|
||||
({"v3t": True}, True, True),
|
||||
({"v3t": False}, True, False),
|
||||
({"v3t": False}, False, True),
|
||||
],
|
||||
)
|
||||
def test_runtime_policy_only_rejects_explicit_free_threaded_refusal(
|
||||
metadata: object,
|
||||
free_threaded: bool,
|
||||
compatible: bool,
|
||||
) -> None:
|
||||
"""运行时判据只在 free-threaded 且显式声明不支持时拒绝。"""
|
||||
result, message = check_plugin_runtime_compatibility(
|
||||
metadata,
|
||||
free_threaded=free_threaded,
|
||||
)
|
||||
|
||||
assert result is compatible
|
||||
assert bool(message) is not compatible
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("metadata", "generation", "current", "expected"),
|
||||
[
|
||||
@@ -219,7 +245,6 @@ def test_generation_policy_covers_all_owner_decisions(
|
||||
metadata,
|
||||
generation,
|
||||
current_generation=current,
|
||||
free_threaded=False,
|
||||
) is expected
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ def _lifecycle(*, plugins, running=None):
|
||||
enable_events=MagicMock(),
|
||||
disable_events=MagicMock(),
|
||||
runtime_status_writer=MagicMock(),
|
||||
runtime_compatible=lambda _pid: True,
|
||||
database=lambda: PluginDatabase(),
|
||||
log=MagicMock(),
|
||||
event_sender=MagicMock(),
|
||||
|
||||
@@ -70,6 +70,7 @@ def _lifecycle(*, plugin_class: type, running: dict) -> PluginLifecycle:
|
||||
enable_events=MagicMock(),
|
||||
disable_events=MagicMock(),
|
||||
runtime_status_writer=lambda _plugin_id, _status: None,
|
||||
runtime_compatible=lambda _plugin_id: True,
|
||||
database=lambda: MagicMock(),
|
||||
log=MagicMock(),
|
||||
event_sender=MagicMock(),
|
||||
|
||||
@@ -32,6 +32,7 @@ def _lifecycle(
|
||||
remove_classification=None,
|
||||
enable_events=None,
|
||||
log=None,
|
||||
runtime_compatible=True,
|
||||
):
|
||||
"""构造隔离外部事件和模块清理的生命周期实例。"""
|
||||
classes = {}
|
||||
@@ -49,6 +50,7 @@ def _lifecycle(
|
||||
enable_events=enable_events or MagicMock(),
|
||||
disable_events=MagicMock(),
|
||||
runtime_status_writer=statuses.__setitem__,
|
||||
runtime_compatible=lambda _plugin_id: runtime_compatible,
|
||||
database=lambda: PluginDatabase(),
|
||||
log=log or MagicMock(),
|
||||
event_sender=MagicMock(),
|
||||
@@ -125,6 +127,20 @@ def test_lifecycle_records_load_failure_when_loader_returns_no_class():
|
||||
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
|
||||
|
||||
|
||||
def test_lifecycle_reports_incompatible_runtime_instead_of_load_failure():
|
||||
"""载荷声明与当前运行时不兼容时,卡片要显示不支持而不是加载失败。"""
|
||||
lifecycle, _classes, running, statuses = _lifecycle(
|
||||
plugins=[],
|
||||
runtime_compatible=False,
|
||||
)
|
||||
|
||||
result = lifecycle.start("DemoPlugin")
|
||||
|
||||
assert result == {"DemoPlugin": PluginRuntimeStatus.INCOMPATIBLE_RUNTIME}
|
||||
assert running == {}
|
||||
assert statuses["DemoPlugin"] is PluginRuntimeStatus.INCOMPATIBLE_RUNTIME
|
||||
|
||||
|
||||
def test_classification_registration_precedes_event_enablement():
|
||||
"""启用插件必须先刷新分类声明,再开放可能产生扩展事实的事件入口。"""
|
||||
order: list[str] = []
|
||||
|
||||
@@ -257,6 +257,41 @@ def test_local_plugin_candidate_keeps_system_version_gate_outside_dev(
|
||||
assert "MoviePilot 版本 >=2.13.11" in candidate["skip_reason"]
|
||||
|
||||
|
||||
def test_local_plugin_candidate_marks_runtime_incompatible_as_unusable(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
plugin_manager: PluginManager,
|
||||
) -> None:
|
||||
"""free-threaded 运行时下声明不支持的本地候选必须投影为不可安装。
|
||||
|
||||
本地热同步只认 compatible 位;漏掉运行时这一位,每次源码变更都会发起一次
|
||||
必然被安装准入拒绝的安装,只留下一条无意义的失败日志。
|
||||
"""
|
||||
repo_path, source_file = _build_local_plugin_repo(tmp_path)
|
||||
(repo_path / "package.v2.json").write_text(
|
||||
'{"DemoPlugin": {"version": "1.0.0", "v3t": false}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
_patch_plugin_runtime_settings(
|
||||
monkeypatch,
|
||||
SimpleNamespace(
|
||||
DEV=False,
|
||||
ROOT_PATH=tmp_path,
|
||||
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
|
||||
),
|
||||
)
|
||||
from app.adapters.external.plugin import client as plugin_client_module
|
||||
|
||||
monkeypatch.setattr(plugin_client_module, "is_free_threaded_runtime", lambda: True)
|
||||
|
||||
candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file)
|
||||
|
||||
assert candidate["runtime_compatible"] is False
|
||||
assert candidate["compatible"] is False
|
||||
assert "v3t" in candidate["skip_reason"]
|
||||
|
||||
|
||||
def test_local_plugin_sync_without_candidate_respects_system_version_gate(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_market_sync_keeps_install_rollback_enabled() -> None:
|
||||
plugin_name="Demo",
|
||||
plugin_version="1.0.0",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
service = PluginSyncService(
|
||||
@@ -84,6 +85,7 @@ def test_market_sync_arbitrates_online_versions_before_local_overlay() -> None:
|
||||
plugin_name="Demo old",
|
||||
plugin_version="1.0.0",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
new = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
@@ -91,6 +93,7 @@ def test_market_sync_arbitrates_online_versions_before_local_overlay() -> None:
|
||||
plugin_name="Demo new",
|
||||
plugin_version="2.0.0",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
|
||||
@@ -120,6 +123,7 @@ def test_market_sync_restores_trusted_online_payload_after_local_source_removed(
|
||||
plugin_name="Demo",
|
||||
plugin_version="1.2.0",
|
||||
system_version_compatible=False,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
service = PluginSyncService(
|
||||
@@ -139,6 +143,68 @@ def test_market_sync_restores_trusted_online_payload_after_local_source_removed(
|
||||
install.assert_called_once_with(plugin.id, None, False, None)
|
||||
|
||||
|
||||
def test_market_sync_excludes_runtime_incompatible_candidates() -> None:
|
||||
"""运行时声明不兼容的插件不得进入启动同步。
|
||||
|
||||
这类插件装不上:安装准入必然拒绝,同步失败状态会覆盖加载器写下的不兼容状态,
|
||||
恢复集合还会把整个启动同步判为未完成,连带跳过依赖恢复与调度器初始化。
|
||||
"""
|
||||
plugin = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
plugin_name="Demo",
|
||||
plugin_version="1.0.0",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=False,
|
||||
)
|
||||
install = Mock(return_value=(False, "插件声明不支持 free-threaded 运行时(v3t)"))
|
||||
status_writer = Mock()
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
installed_plugins=lambda: [plugin.id],
|
||||
online_plugins=lambda: [plugin],
|
||||
local_plugins=lambda: [],
|
||||
merge_plugins=lambda items, *_args: items,
|
||||
# 运行目录缺失是这类插件在 v3t 上的常态:加载器跳过后它不在运行表里
|
||||
plugin_exists=lambda *_args: False,
|
||||
install=install,
|
||||
log=Mock(),
|
||||
runtime_status_writer=status_writer,
|
||||
)
|
||||
|
||||
assert service.sync() == []
|
||||
install.assert_not_called()
|
||||
# 不能用同步失败状态覆盖加载器写下的 INCOMPATIBLE_RUNTIME
|
||||
status_writer.assert_not_called()
|
||||
|
||||
|
||||
def test_market_sync_excludes_runtime_incompatible_restore_targets() -> None:
|
||||
"""恢复分支同样排除:重装当前运行时装不上的插件不会有别的结果。"""
|
||||
plugin = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
plugin_name="Demo",
|
||||
plugin_version="1.0.0",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=False,
|
||||
)
|
||||
install = Mock(return_value=(False, "插件声明不支持 free-threaded 运行时(v3t)"))
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
installed_plugins=lambda: [plugin.id],
|
||||
online_plugins=lambda: [plugin],
|
||||
local_plugins=lambda: [],
|
||||
merge_plugins=lambda items, *_args: items,
|
||||
plugin_exists=lambda *_args: True,
|
||||
install=install,
|
||||
log=Mock(),
|
||||
)
|
||||
|
||||
# 恢复失败会抛 RuntimeError 中断启动同步,这里必须连候选都不产生
|
||||
assert service.sync(online_restore_plugins={"demoplugin"}) == []
|
||||
install.assert_not_called()
|
||||
|
||||
|
||||
def test_market_sync_skips_existing_local_candidate() -> None:
|
||||
"""运行目录已有可用插件时,启动同步不因本地候选重复安装。"""
|
||||
online = SimpleNamespace(
|
||||
@@ -147,6 +213,7 @@ def test_market_sync_skips_existing_local_candidate() -> None:
|
||||
plugin_name="Demo",
|
||||
plugin_version="1.2.0",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
local = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
@@ -154,6 +221,7 @@ def test_market_sync_skips_existing_local_candidate() -> None:
|
||||
plugin_name="Demo Local",
|
||||
plugin_version="9.9.10",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
service = PluginSyncService(
|
||||
@@ -179,6 +247,7 @@ def test_market_sync_defers_source_selection_to_gateway() -> None:
|
||||
plugin_name="Demo Local",
|
||||
plugin_version="3.3.2",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
service = PluginSyncService(
|
||||
@@ -204,6 +273,7 @@ def test_market_sync_reports_local_install_failure() -> None:
|
||||
plugin_name="Demo Local",
|
||||
plugin_version="3.3.2",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
@@ -359,6 +429,7 @@ async def test_market_sync_preserves_generation_priority_through_gateway(
|
||||
plugin_name="Demo Local",
|
||||
plugin_version=local.plugin_version,
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
merged_online = SimpleNamespace(
|
||||
id=online.plugin_id,
|
||||
@@ -366,6 +437,7 @@ async def test_market_sync_preserves_generation_priority_through_gateway(
|
||||
plugin_name="Download Center",
|
||||
plugin_version=online.plugin_version,
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
@@ -497,6 +569,7 @@ async def test_market_sync_blocks_activation_when_gateway_selected_local_fails(
|
||||
plugin_name="Demo Official",
|
||||
plugin_version=official.plugin_version,
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
merged_competing = SimpleNamespace(
|
||||
id=competing.plugin_id,
|
||||
@@ -504,6 +577,7 @@ async def test_market_sync_blocks_activation_when_gateway_selected_local_fails(
|
||||
plugin_name="Demo Competing",
|
||||
plugin_version=competing.plugin_version,
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
merged_local = SimpleNamespace(
|
||||
id=local.plugin_id,
|
||||
@@ -511,6 +585,7 @@ async def test_market_sync_blocks_activation_when_gateway_selected_local_fails(
|
||||
plugin_name="Demo Local",
|
||||
plugin_version=local.plugin_version,
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
@@ -548,6 +623,7 @@ async def test_market_sync_reuses_startup_lease_through_real_gateway(
|
||||
plugin_name="Demo",
|
||||
plugin_version="9.0.0",
|
||||
system_version_compatible=True,
|
||||
runtime_compatible=True,
|
||||
)
|
||||
official_candidate = PluginMarketCandidate(
|
||||
plugin_id=plugin.id,
|
||||
|
||||
@@ -648,6 +648,46 @@ def test_loader_runtime_gate_only_rejects_explicit_incompatible_declarations(
|
||||
assert not PluginLoader._is_runtime_compatible(rejected)
|
||||
|
||||
|
||||
def test_loader_marks_incompatible_runtime_status_when_skipping(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""运行时不兼容的插件被跳过时必须留下卡片可见的状态。
|
||||
|
||||
启动期全量加载不会让生命周期遍历到这些插件,状态只能由加载器落记;
|
||||
否则用户在 v3t 上只能看到一张既不运行也不解释的占位卡片。
|
||||
"""
|
||||
from app.runtime.extensions.plugin import loader as loader_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
loader_module,
|
||||
"get_runtime_setting",
|
||||
lambda key: "v3" if key == "VERSION_FLAG" else None,
|
||||
)
|
||||
monkeypatch.setattr(loader_module, "is_free_threaded_runtime", lambda: True)
|
||||
|
||||
plugin_dir = tmp_path / "rejectedplugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "__init__.py").write_text("", encoding="utf-8")
|
||||
(plugin_dir / "package.json").write_text('{"v3t": false}', encoding="utf-8")
|
||||
|
||||
statuses: dict[str, PluginRuntimeStatus] = {}
|
||||
loader = PluginLoader(
|
||||
plugins_root=tmp_path,
|
||||
import_preparer=lambda **_kwargs: None,
|
||||
import_scanner=lambda **_kwargs: None,
|
||||
runtime_status_writer=statuses.__setitem__,
|
||||
log=_logger(),
|
||||
)
|
||||
|
||||
assert loader.load(None, ["RejectedPlugin"], lambda _candidate: True) == []
|
||||
# 安装清单里的原始大小写必须保留:卡片按该 ID 读状态
|
||||
assert statuses == {
|
||||
"RejectedPlugin": PluginRuntimeStatus.INCOMPATIBLE_RUNTIME
|
||||
}
|
||||
assert not loader.is_runtime_compatible("RejectedPlugin")
|
||||
|
||||
|
||||
def test_clone_service_persists_descriptor_without_copying_source_package():
|
||||
"""创建分身只写实例描述和隔离配置,并始终跟随源插件版本。"""
|
||||
instances: dict[str, PluginInstance] = {}
|
||||
|
||||
Reference in New Issue
Block a user