diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index f9acfe59b..a9cd9e610 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -32,6 +32,7 @@ from importlib.metadata import distributions from requests import Response from app.runtime.cache import cached, is_fresh +from app.foundation.environment import is_free_threaded_runtime from app.runtime.dependencies import ( iter_runtime_profile_requirement_strings, iter_runtime_requirement_strings, @@ -369,6 +370,8 @@ class PluginHelper(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') @@ -397,6 +400,8 @@ class PluginHelper(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 diff --git a/app/application/plugin/inventory.py b/app/application/plugin/inventory.py index 47aa04e51..d6bb2925f 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.foundation.environment import is_free_threaded_runtime PLUGIN_V3_GENERATIONS = ("v3", "v2", "v1") PluginIndex: TypeAlias = Mapping[str, Mapping[str, Any]] @@ -383,6 +384,8 @@ def _is_v3_compatible( """按宿主 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 diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index d7da0dce9..282fe7d6e 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib import importlib.util +import json import sys import threading import traceback @@ -11,6 +12,8 @@ from collections.abc import Callable from pathlib import Path 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 @@ -73,6 +76,11 @@ class PluginLoader: f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)" ) continue + if not self._is_runtime_compatible(plugin_dir): + self._logger.warning( + f"跳过插件 {plugin_dir.name}:声明与当前运行时不兼容" + ) + continue try: module_name = f"app.plugins.{plugin_dir.name}" @@ -115,6 +123,11 @@ class PluginLoader: f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}" ) 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()}" self.clear_modules(instance.instance_id) @@ -161,6 +174,21 @@ class PluginLoader: ) 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( self, *, diff --git a/tests/fixtures/architecture/coverage-baseline.json b/tests/fixtures/architecture/coverage-baseline.json index 21cdc58bb..f56ef7fc2 100644 --- a/tests/fixtures/architecture/coverage-baseline.json +++ b/tests/fixtures/architecture/coverage-baseline.json @@ -1,8 +1,8 @@ { "application": { - "covered_lines": 9309, - "percent": 77.8, - "statements": 11965 + "covered_lines": 9312, + "percent": 77.81, + "statements": 11968 }, "domain": { "covered_lines": 3390, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index b5f78a09f..f6f17a9cd 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -179,8 +179,8 @@ "app.adapters.system.host" ] }, - "edge_count": 6810, - "edge_sha256": "141ed79f9097aaed4b1933f2fe931b7364e7a1a88d63ea8ca79a6012255f8d9c", + "edge_count": 6817, + "edge_sha256": "e3d43fec9f7bc936ef5a2ffe7ba11ea054d1ba7d1c42e7101978480cd99a63fe", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -221,6 +221,7 @@ "app.adapters.external.market -> app.adapters.system.plugin", "app.adapters.external.market -> app.adapters.system.plugin.manifest", "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", @@ -2961,6 +2962,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.foundation", + "app.application.plugin.inventory -> app.foundation.environment", "app.application.plugin.recovery -> app.application", "app.application.plugin.recovery -> app.application.plugin", "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.schemas", "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.plugin", "app.runtime.extensions.plugin.metadata -> app.runtime", diff --git a/tests/test_plugin_candidate_inventory.py b/tests/test_plugin_candidate_inventory.py index 07a848c71..4ec1796de 100644 --- a/tests/test_plugin_candidate_inventory.py +++ b/tests/test_plugin_candidate_inventory.py @@ -82,6 +82,27 @@ 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, + ) + + 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: """官方仓库使用官方来源类型,候选公共字段与 Plugin schema 对齐。""" reader = PluginCandidateInventoryReader( diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 4fbac8366..fb26e29fb 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -1006,6 +1006,27 @@ class TestPluginHelper: ) 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): """ 全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。 diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py index 5ae5f6299..94d68e3bd 100644 --- a/tests/test_plugin_virtual_instances.py +++ b/tests/test_plugin_virtual_instances.py @@ -113,6 +113,35 @@ def test_loader_executes_each_instance_in_an_isolated_module_namespace( 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(): """创建分身只写实例描述和隔离配置,并始终跟随源插件版本。""" instances: dict[str, PluginInstance] = {}