mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
fix: 增加插件 V3t 运行时兼容门禁 (#6475)
* fix(plugin): enforce declared runtime compatibility * chore(ci): record plugin runtime compatibility edges --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
Vendored
+5
@@ -32,6 +32,7 @@ from importlib.metadata import distributions
|
|||||||
from requests import Response
|
from requests import Response
|
||||||
|
|
||||||
from app.runtime.cache import cached, is_fresh
|
from app.runtime.cache import cached, is_fresh
|
||||||
|
from app.foundation.environment import is_free_threaded_runtime
|
||||||
from app.runtime.dependencies import (
|
from app.runtime.dependencies import (
|
||||||
iter_runtime_profile_requirement_strings,
|
iter_runtime_profile_requirement_strings,
|
||||||
iter_runtime_requirement_strings,
|
iter_runtime_requirement_strings,
|
||||||
@@ -369,6 +370,8 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
"""
|
"""
|
||||||
if not isinstance(plugin_info, dict):
|
if not isinstance(plugin_info, dict):
|
||||||
return False
|
return False
|
||||||
|
if is_free_threaded_runtime() and plugin_info.get("v3t") is False:
|
||||||
|
return False
|
||||||
if not get_runtime_setting('VERSION_FLAG'):
|
if not get_runtime_setting('VERSION_FLAG'):
|
||||||
return True
|
return True
|
||||||
current_flag = get_runtime_setting('VERSION_FLAG')
|
current_flag = get_runtime_setting('VERSION_FLAG')
|
||||||
@@ -397,6 +400,8 @@ class PluginHelper(metaclass=WeakSingleton):
|
|||||||
"""
|
"""
|
||||||
if not isinstance(plugin_info, dict):
|
if not isinstance(plugin_info, dict):
|
||||||
return False
|
return False
|
||||||
|
if is_free_threaded_runtime() and plugin_info.get("v3t") is False:
|
||||||
|
return False
|
||||||
current_flag = get_runtime_setting('VERSION_FLAG')
|
current_flag = get_runtime_setting('VERSION_FLAG')
|
||||||
if not current_flag:
|
if not current_flag:
|
||||||
return not package_version
|
return not package_version
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.application.plugin.source import (
|
|||||||
PluginMarketCandidate,
|
PluginMarketCandidate,
|
||||||
normalize_package_generation,
|
normalize_package_generation,
|
||||||
)
|
)
|
||||||
|
from app.foundation.environment import is_free_threaded_runtime
|
||||||
|
|
||||||
PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1")
|
PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1")
|
||||||
PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]]
|
PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]]
|
||||||
@@ -383,6 +384,8 @@ def _is_v3_compatible(
|
|||||||
"""按宿主 V3、兼容 V2、基础索引顺序判断候选兼容性。"""
|
"""按宿主 V3、兼容 V2、基础索引顺序判断候选兼容性。"""
|
||||||
if plugin_info.get("v3") is False:
|
if plugin_info.get("v3") is False:
|
||||||
return False
|
return False
|
||||||
|
if is_free_threaded_runtime() and plugin_info.get("v3t") is False:
|
||||||
|
return False
|
||||||
if package_generation in {"v3", "v2"}:
|
if package_generation in {"v3", "v2"}:
|
||||||
return True
|
return True
|
||||||
return plugin_info.get("v3") is True or plugin_info.get("v2") is True
|
return plugin_info.get("v3") is True or plugin_info.get("v2") is True
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
@@ -11,6 +12,8 @@ from collections.abc import Callable
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +76,11 @@ class PluginLoader:
|
|||||||
f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)"
|
f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if not self._is_runtime_compatible(plugin_dir):
|
||||||
|
self._logger.warning(
|
||||||
|
f"跳过插件 {plugin_dir.name}:声明与当前运行时不兼容"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module_name = f"app.plugins.{plugin_dir.name}"
|
module_name = f"app.plugins.{plugin_dir.name}"
|
||||||
@@ -115,6 +123,11 @@ class PluginLoader:
|
|||||||
f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}"
|
f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}"
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
if not self._is_runtime_compatible(source_dir):
|
||||||
|
self._logger.warning(
|
||||||
|
f"跳过虚拟插件实例 {instance.instance_id}:声明与当前运行时不兼容"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
module_name = f"app.plugins.{instance.instance_id.lower()}"
|
module_name = f"app.plugins.{instance.instance_id.lower()}"
|
||||||
self.clear_modules(instance.instance_id)
|
self.clear_modules(instance.instance_id)
|
||||||
@@ -161,6 +174,21 @@ class PluginLoader:
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_runtime_compatible(plugin_dir: Path) -> bool:
|
||||||
|
"""按载荷自身 package 声明执行运行时兼容门禁,缺失声明时保持兼容。"""
|
||||||
|
package_file = plugin_dir / "package.json"
|
||||||
|
try:
|
||||||
|
package = json.loads(package_file.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
return True
|
||||||
|
if not isinstance(package, dict):
|
||||||
|
return True
|
||||||
|
version_flag = get_runtime_setting("VERSION_FLAG")
|
||||||
|
if version_flag and package.get(version_flag) is False:
|
||||||
|
return False
|
||||||
|
return not (is_free_threaded_runtime() and package.get("v3t") is False)
|
||||||
|
|
||||||
def _execute_instance_module(
|
def _execute_instance_module(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"application": {
|
"application": {
|
||||||
"covered_lines": 9309,
|
"covered_lines": 9312,
|
||||||
"percent": 77.8,
|
"percent": 77.81,
|
||||||
"statements": 11965
|
"statements": 11968
|
||||||
},
|
},
|
||||||
"domain": {
|
"domain": {
|
||||||
"covered_lines": 3390,
|
"covered_lines": 3390,
|
||||||
|
|||||||
+9
-2
@@ -179,8 +179,8 @@
|
|||||||
"app.adapters.system.host"
|
"app.adapters.system.host"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"edge_count": 6810,
|
"edge_count": 6817,
|
||||||
"edge_sha256": "141ed79f9097aaed4b1933f2fe931b7364e7a1a88d63ea8ca79a6012255f8d9c",
|
"edge_sha256": "e3d43fec9f7bc936ef5a2ffe7ba11ea054d1ba7d1c42e7101978480cd99a63fe",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -221,6 +221,7 @@
|
|||||||
"app.adapters.external.market -> app.adapters.system.plugin",
|
"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.manifest",
|
||||||
"app.adapters.external.market -> app.foundation",
|
"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.singleton",
|
||||||
"app.adapters.external.market -> app.foundation.url",
|
"app.adapters.external.market -> app.foundation.url",
|
||||||
"app.adapters.external.market -> app.foundation.version",
|
"app.adapters.external.market -> app.foundation.version",
|
||||||
@@ -2961,6 +2962,8 @@
|
|||||||
"app.application.plugin.inventory -> app.application.plugin",
|
"app.application.plugin.inventory -> app.application.plugin",
|
||||||
"app.application.plugin.inventory -> app.application.plugin.identity",
|
"app.application.plugin.inventory -> app.application.plugin.identity",
|
||||||
"app.application.plugin.inventory -> app.application.plugin.source",
|
"app.application.plugin.inventory -> app.application.plugin.source",
|
||||||
|
"app.application.plugin.inventory -> app.foundation",
|
||||||
|
"app.application.plugin.inventory -> app.foundation.environment",
|
||||||
"app.application.plugin.recovery -> app.application",
|
"app.application.plugin.recovery -> app.application",
|
||||||
"app.application.plugin.recovery -> app.application.plugin",
|
"app.application.plugin.recovery -> app.application.plugin",
|
||||||
"app.application.plugin.recovery -> app.application.plugin.install",
|
"app.application.plugin.recovery -> app.application.plugin.install",
|
||||||
@@ -6085,6 +6088,10 @@
|
|||||||
"app.runtime.extensions.plugin.lifecycle -> app.runtime.observability",
|
"app.runtime.extensions.plugin.lifecycle -> app.runtime.observability",
|
||||||
"app.runtime.extensions.plugin.lifecycle -> app.schemas",
|
"app.runtime.extensions.plugin.lifecycle -> app.schemas",
|
||||||
"app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin",
|
"app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin",
|
||||||
|
"app.runtime.extensions.plugin.loader -> app.foundation",
|
||||||
|
"app.runtime.extensions.plugin.loader -> app.foundation.environment",
|
||||||
|
"app.runtime.extensions.plugin.loader -> app.runtime",
|
||||||
|
"app.runtime.extensions.plugin.loader -> app.runtime.settings",
|
||||||
"app.runtime.extensions.plugin.loader -> app.schemas",
|
"app.runtime.extensions.plugin.loader -> app.schemas",
|
||||||
"app.runtime.extensions.plugin.loader -> app.schemas.plugin",
|
"app.runtime.extensions.plugin.loader -> app.schemas.plugin",
|
||||||
"app.runtime.extensions.plugin.metadata -> app.runtime",
|
"app.runtime.extensions.plugin.metadata -> app.runtime",
|
||||||
|
|||||||
@@ -82,6 +82,27 @@ def test_only_v3_compatible_entries_are_candidates() -> None:
|
|||||||
assert not inventory.candidates_for("Undeclared")
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
inventory = PluginCandidateInventoryReader(
|
||||||
|
market_loader=lambda *_args: {
|
||||||
|
"Allowed": {"version": "1.0.0"},
|
||||||
|
"Rejected": {"version": "1.0.0", "v3t": False},
|
||||||
|
},
|
||||||
|
).load([THIRD_PARTY_MARKET])
|
||||||
|
|
||||||
|
assert {candidate.plugin_id for candidate in inventory.online_candidates} == {
|
||||||
|
"Allowed"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_official_source_is_classified_and_public_candidate_uses_plugin_version() -> None:
|
def test_official_source_is_classified_and_public_candidate_uses_plugin_version() -> None:
|
||||||
"""官方仓库使用官方来源类型,候选公共字段与 Plugin schema 对齐。"""
|
"""官方仓库使用官方来源类型,候选公共字段与 Plugin schema 对齐。"""
|
||||||
reader = PluginCandidateInventoryReader(
|
reader = PluginCandidateInventoryReader(
|
||||||
|
|||||||
@@ -1006,6 +1006,27 @@ class TestPluginHelper:
|
|||||||
)
|
)
|
||||||
assert not PluginHelper.is_package_plugin_compatible({}, "")
|
assert not PluginHelper.is_package_plugin_compatible({}, "")
|
||||||
|
|
||||||
|
def test_free_threaded_package_compatibility_honors_explicit_v3t_false(
|
||||||
|
self,
|
||||||
|
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(
|
||||||
|
market_module,
|
||||||
|
"settings",
|
||||||
|
SimpleNamespace(VERSION_FLAG="v3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
|
def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
|
||||||
"""
|
"""
|
||||||
全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。
|
全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。
|
||||||
|
|||||||
@@ -113,6 +113,35 @@ def test_loader_executes_each_instance_in_an_isolated_module_namespace(
|
|||||||
assert plugin_package.demoplugin is source_module
|
assert plugin_package.demoplugin is source_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_loader_runtime_gate_only_rejects_explicit_incompatible_declarations(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""运行目录缺少声明或 runtime 为空时保持历史插件可加载。"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
missing = tmp_path / "missing"
|
||||||
|
missing.mkdir()
|
||||||
|
assert PluginLoader._is_runtime_compatible(missing)
|
||||||
|
|
||||||
|
empty = tmp_path / "empty"
|
||||||
|
empty.mkdir()
|
||||||
|
(empty / "package.json").write_text('{"runtime": {}}', encoding="utf-8")
|
||||||
|
assert PluginLoader._is_runtime_compatible(empty)
|
||||||
|
|
||||||
|
rejected = tmp_path / "rejected"
|
||||||
|
rejected.mkdir()
|
||||||
|
(rejected / "package.json").write_text('{"v3t": false}', encoding="utf-8")
|
||||||
|
assert not PluginLoader._is_runtime_compatible(rejected)
|
||||||
|
|
||||||
|
|
||||||
def test_clone_service_persists_descriptor_without_copying_source_package():
|
def test_clone_service_persists_descriptor_without_copying_source_package():
|
||||||
"""创建分身只写实例描述和隔离配置,并始终跟随源插件版本。"""
|
"""创建分身只写实例描述和隔离配置,并始终跟随源插件版本。"""
|
||||||
instances: dict[str, PluginInstance] = {}
|
instances: dict[str, PluginInstance] = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user