fix(plugin): optimize release cache loading (#5966)

This commit is contained in:
InfinityPacer
2026-06-18 18:52:28 +08:00
committed by GitHub
parent d2e2435be7
commit 84eee40e81
5 changed files with 430 additions and 58 deletions

View File

@@ -347,6 +347,8 @@ async def plugin_releases(
) -> dict:
"""
查询指定插件可直接安装的 GitHub Release 版本。
市场元数据只读取请求仓库的当前 package避免版本历史请求触发全部市场缓存读取。
"""
if not repo_url:
return {
@@ -357,35 +359,32 @@ async def plugin_releases(
}
plugin_manager = PluginManager()
online_plugins = await plugin_manager.async_get_online_plugins(force=force)
market_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, settings.VERSION_FLAG, force
)
market_plugin = next(
(
plugin
for plugin in online_plugins
if plugin.id == plugin_id and plugin.repo_url == repo_url
for plugin in market_plugins or []
if plugin.id == plugin_id
),
None,
)
if not market_plugin:
if not market_plugin and settings.VERSION_FLAG:
compatible_plugins = await plugin_manager.async_get_plugins_from_market(
repo_url, None, force
)
market_plugin = next(
(
plugin
for plugin in online_plugins
for plugin in compatible_plugins or []
if plugin.id == plugin_id
),
None,
)
installed_plugin = next(
(
plugin
for plugin in plugin_manager.get_local_plugins()
if plugin.id == plugin_id and plugin.installed
),
None,
)
latest_version = market_plugin.plugin_version if market_plugin else None
current_version = installed_plugin.plugin_version if installed_plugin else None
current_version = plugin_manager.get_local_plugin_version(plugin_id)
if not getattr(market_plugin, "release", False):
return {
"release_supported": False,

View File

@@ -1199,8 +1199,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
if not settings.PLUGIN_MARKET:
return []
if force:
PluginHelper().get_plugin_release_versions.cache_clear()
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
higher_version_plugins = []
@@ -1311,6 +1309,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
return plugins
def get_local_plugin_version(self, pid: str) -> Optional[str]:
"""
获取指定已安装插件的本地版本,不触发全部插件的状态、页面和权限计算。
插件类由运行期动态加载,旧插件可能未声明版本属性,因此缺失时返回 None。
"""
installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
if pid not in installed_apps:
return None
plugin_class = self._plugins.get(pid)
if not plugin_class:
return None
return getattr(plugin_class, "plugin_version", None)
def get_local_repo_plugins(self) -> List[schemas.Plugin]:
"""
获取本地插件仓库目录中的插件信息
@@ -1566,8 +1578,6 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
if not settings.PLUGIN_MARKET:
return []
if force:
await PluginHelper().async_get_plugin_release_versions.cache_clear()
# 用于存储高于 v1 版本的插件(如 v2, v3 等)
higher_version_plugins = []

View File

@@ -51,6 +51,9 @@ class PluginHelper(metaclass=WeakSingleton):
_base_url = "https://raw.githubusercontent.com/{user}/{repo}/main/"
# 串行化运行期依赖安装,避免多个 pip 子进程和导入缓存刷新互相踩踏。
_pip_install_lock = threading.Lock()
# 同仓库的并发 Release 请求共享任务;事件循环参与键控,避免热重载或测试循环切换后复用失效任务。
_release_task_lock = threading.Lock()
_release_tasks: Dict[Tuple[asyncio.AbstractEventLoop, str, bool], asyncio.Task] = {}
# 这些包一旦被插件覆盖,最容易直接拖垮主程序启动,因此冲突提示需要单独高亮。
_protected_runtime_packages = frozenset({
"alembic",
@@ -458,6 +461,27 @@ class PluginHelper(metaclass=WeakSingleton):
releases.append(item)
return releases
@staticmethod
def __normalize_plugin_release_response(payload) -> List[dict]:
"""仅保留版本展示和资产匹配所需字段,控制仓库级缓存体积。"""
if not isinstance(payload, list):
return []
return [
{
"tag_name": release_info.get("tag_name"),
"name": release_info.get("name"),
"published_at": release_info.get("published_at"),
"body": release_info.get("body"),
"assets": [
{"name": asset.get("name")}
for asset in release_info.get("assets") or []
if isinstance(asset, dict)
],
}
for release_info in payload
if isinstance(release_info, dict)
]
@cached(maxsize=128, ttl=1800)
def get_plugins(self, repo_url: str,
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
@@ -486,12 +510,12 @@ class PluginHelper(metaclass=WeakSingleton):
return None
return self.__parse_plugin_index_response(res.text)
@cached(maxsize=128, ttl=1800)
def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
@cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases")
def _get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
"""
获取插件可安装的 GitHub Release 版本列表
按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享
"""
if not pid or not repo_url:
if not repo_url:
return []
user, repo = self.get_repo_info(repo_url)
@@ -510,20 +534,32 @@ class PluginHelper(metaclass=WeakSingleton):
is_api=True,
)
if res is None or res.status_code != 200:
break
return None
try:
payload = res.json()
if not payload:
break
releases.extend(self.__parse_plugin_release_response(pid, payload))
if not isinstance(payload, list):
return None
releases.extend(self.__normalize_plugin_release_response(payload))
if len(payload) < 100:
break
except Exception as e:
logger.error(f"解析插件 {pid} Release 列表失败:{e}")
break
logger.error(f"解析插件仓库 {repo_url} Release 列表失败:{e}")
return None
return releases
def get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
"""
获取插件可安装的 GitHub Release 版本列表。
GitHub 分页结果按仓库缓存,插件 ID 只参与本地过滤,避免同仓库重复分页。
"""
if not pid or not repo_url:
return []
return self.__parse_plugin_release_response(pid, self._get_plugin_repo_releases(repo_url.rstrip("/")))
@staticmethod
def __has_installable_release_version(release_items: List[dict], release_version: str) -> bool:
"""
@@ -2000,12 +2036,12 @@ class PluginHelper(metaclass=WeakSingleton):
return None
return self.__parse_plugin_index_response(res.text)
@cached(maxsize=128, ttl=1800)
async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
@cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases")
async def _async_get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
"""
异步获取插件可安装的 GitHub Release 版本列表
异步按仓库获取 GitHub Release 原始分页数据
"""
if not pid or not repo_url:
if not repo_url:
return []
user, repo = self.get_repo_info(repo_url)
@@ -2024,20 +2060,85 @@ class PluginHelper(metaclass=WeakSingleton):
is_api=True,
)
if res is None or res.status_code != 200:
break
return None
try:
payload = res.json()
if not payload:
break
releases.extend(self.__parse_plugin_release_response(pid, payload))
if not isinstance(payload, list):
return None
releases.extend(self.__normalize_plugin_release_response(payload))
if len(payload) < 100:
break
except Exception as e:
logger.error(f"解析插件 {pid} Release 列表失败:{e}")
break
logger.error(f"解析插件仓库 {repo_url} Release 列表失败:{e}")
return None
return releases
async def async_get_plugin_release_versions(self, pid: str, repo_url: str) -> List[dict]:
"""
异步获取插件可安装的 GitHub Release 版本列表。
同一事件循环内,同仓库的并发读取和强制刷新共享一个请求任务。
"""
if not pid or not repo_url:
return []
loop = asyncio.get_running_loop()
normalized_repo_url = repo_url.rstrip("/")
normal_task_key = (loop, normalized_repo_url, False)
force_task_key = (loop, normalized_repo_url, True)
with self._release_task_lock:
force_task = self._release_tasks.get(force_task_key)
if force_task and not force_task.done():
task_key = force_task_key
task = force_task
elif is_fresh():
pending_normal_task = self._release_tasks.get(normal_task_key)
if pending_normal_task and pending_normal_task.done():
pending_normal_task = None
task_key = force_task_key
task = loop.create_task(
self._async_refresh_plugin_repo_releases(normalized_repo_url, pending_normal_task)
)
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
else:
task_key = normal_task_key
task = self._release_tasks.get(task_key)
if task is None or task.done():
task = loop.create_task(self._async_get_plugin_repo_releases(normalized_repo_url))
self._release_tasks[task_key] = task
task.add_done_callback(
lambda completed_task: self._remove_release_task(task_key, completed_task)
)
payload = await asyncio.shield(task)
return self.__parse_plugin_release_response(pid, payload)
async def _async_refresh_plugin_repo_releases(
self,
repo_url: str,
pending_normal_task: Optional[asyncio.Task],
) -> Optional[List[dict]]:
"""等待在途普通读取落盘后执行强刷,确保旧结果不会覆盖强刷缓存。"""
if pending_normal_task:
try:
await asyncio.shield(pending_normal_task)
except (Exception, asyncio.CancelledError):
pass
return await self._async_get_plugin_repo_releases(repo_url)
@classmethod
def _remove_release_task(cls, task_key: Tuple[asyncio.AbstractEventLoop, str, bool], task: asyncio.Task) -> None:
"""请求任务完成后释放事件循环和仓库引用。"""
with cls._release_task_lock:
if cls._release_tasks.get(task_key) is task:
cls._release_tasks.pop(task_key, None)
async def __async_get_file_list(self, pid: str, user_repo: str, package_version: Optional[str] = None) -> \
Tuple[Optional[list], Optional[str]]:
"""
@@ -2665,3 +2766,10 @@ class PluginHelper(metaclass=WeakSingleton):
except Exception as e:
logger.error(f"解压 Release 压缩包失败:{e}")
return False, f"解压 Release 压缩包失败:{e}"
# 公开 Release 查询的缓存管理统一指向仓库级分页缓存。
PluginHelper.get_plugin_release_versions.cache_clear = PluginHelper._get_plugin_repo_releases.cache_clear
PluginHelper.get_plugin_release_versions.cache_region = PluginHelper._get_plugin_repo_releases.cache_region
PluginHelper.async_get_plugin_release_versions.cache_clear = PluginHelper._async_get_plugin_repo_releases.cache_clear
PluginHelper.async_get_plugin_release_versions.cache_region = PluginHelper._async_get_plugin_repo_releases.cache_region

View File

@@ -6,6 +6,7 @@ from app.api.endpoints.plugin import plugin_history
from app.api.endpoints.plugin import plugin_releases
from app.api.endpoints.plugin import reset_plugin
from app.api.endpoints.system import sync_plugin_market_from_wiki
from app.core.config import settings
from app.schemas.event import PluginDataResetEventData
from app.schemas.types import ChainEventType
@@ -75,14 +76,10 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk
repo_url="https://github.com/demo/plugins",
release=True,
)
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.0",
installed=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = "1.2.0"
plugin_helper = MagicMock()
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
@@ -102,7 +99,11 @@ def test_plugin_releases_returns_supported_versions_with_latest_and_current(monk
assert result["items"][0]["is_current"] is False
assert result["items"][1]["is_latest"] is False
assert result["items"][1]["is_current"] is True
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=False)
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
"https://github.com/demo/plugins", settings.VERSION_FLAG, False
)
plugin_manager.async_get_online_plugins.assert_not_awaited()
plugin_manager.get_local_plugins.assert_not_called()
def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
@@ -115,17 +116,12 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
repo_url="https://github.com/demo/plugins",
release=True,
)
installed_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.0",
installed=True,
)
release_items = [
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},
]
plugin_manager = MagicMock()
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugins.return_value = [installed_plugin]
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = "1.2.0"
plugin_helper = MagicMock()
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=release_items)
@@ -140,6 +136,39 @@ def test_plugin_releases_does_not_mutate_cached_release_items(monkeypatch):
assert "is_current" not in release_items[0]
def test_plugin_releases_falls_back_to_compatible_base_package(monkeypatch):
"""
当前版本 package 未包含插件时,再读取基础 package 兼容项,不扫描其他市场。
"""
market_plugin = schemas.Plugin(
id="DemoPlugin",
plugin_version="1.2.3",
repo_url="https://github.com/demo/plugins",
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_plugins_from_market = AsyncMock(
side_effect=[[], [market_plugin]]
)
plugin_manager.get_local_plugin_version.return_value = None
plugin_helper = MagicMock()
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
with (
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
patch("app.api.endpoints.plugin.PluginHelper", return_value=plugin_helper),
):
result = asyncio.run(
plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", False)
)
assert result["latest_version"] == "1.2.3"
assert plugin_manager.async_get_plugins_from_market.await_args_list == [
(("https://github.com/demo/plugins", settings.VERSION_FLAG, False), {}),
(("https://github.com/demo/plugins", None, False), {}),
]
def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
"""
release 列表接口沿用插件市场的 force 语义,供前端手动刷新时绕过缓存。
@@ -151,8 +180,8 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
release=True,
)
plugin_manager = MagicMock()
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = None
plugin_helper = MagicMock()
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[])
@@ -163,8 +192,13 @@ def test_plugin_releases_uses_force_refresh_for_market_metadata(monkeypatch):
result = asyncio.run(plugin_releases("DemoPlugin", None, "https://github.com/demo/plugins", True))
assert result["release_supported"] is False
plugin_manager.async_get_online_plugins.assert_awaited_once_with(force=True)
assert plugin_helper.async_get_plugin_release_versions.await_args.args == ("DemoPlugin", "https://github.com/demo/plugins")
plugin_manager.async_get_plugins_from_market.assert_awaited_once_with(
"https://github.com/demo/plugins", settings.VERSION_FLAG, True
)
assert plugin_helper.async_get_plugin_release_versions.await_args.args == (
"DemoPlugin",
"https://github.com/demo/plugins",
)
def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(monkeypatch):
@@ -178,8 +212,8 @@ def test_plugin_releases_hides_items_when_market_plugin_does_not_enable_release(
release=False,
)
plugin_manager = MagicMock()
plugin_manager.async_get_online_plugins = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugins.return_value = []
plugin_manager.async_get_plugins_from_market = AsyncMock(return_value=[market_plugin])
plugin_manager.get_local_plugin_version.return_value = None
plugin_helper = MagicMock()
plugin_helper.async_get_plugin_release_versions = AsyncMock(return_value=[
{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3", "asset_name": "demoplugin_v1.2.3.zip"},

View File

@@ -245,7 +245,11 @@ class TestPluginHelper:
},
]
helper = PluginHelper()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", lambda *_args, **_kwargs: _FakeTextResponse(200, payload))
monkeypatch.setattr(
helper,
"_PluginHelper__request_with_fallback",
lambda *_args, **_kwargs: _FakeTextResponse(200, payload),
)
releases = helper.get_plugin_release_versions(PLUGIN_ID, REPO_URL)
@@ -323,9 +327,177 @@ class TestPluginHelper:
assert requested_pages == ["1", "2"]
assert [item["version"] for item in releases] == ["1.2.0"]
def test_get_online_plugins_force_clears_release_cache(self, monkeypatch):
def test_get_plugin_release_versions_reuses_repository_pages_across_plugins(self, monkeypatch):
"""
插件市场缓存刷新会一并清理 Release 列表缓存,覆盖定时刷新服务入口
同一仓库的不同插件共享 GitHub Release 分页结果,避免按插件 ID 重复请求
"""
try:
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
payload = [
{
"tag_name": "DemoPlugin_v1.2.3",
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
},
{
"tag_name": "OtherPlugin_v2.0.0",
"assets": [{"name": "otherplugin_v2.0.0.zip", "id": 2}],
},
]
request_count = 0
def fake_request(*_args, **_kwargs):
nonlocal request_count
request_count += 1
return _FakeTextResponse(200, payload)
helper = PluginHelper()
helper.get_plugin_release_versions.cache_clear()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request)
demo_releases = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
other_releases = helper.get_plugin_release_versions("OtherPlugin", REPO_URL)
assert request_count == 1
assert [item["version"] for item in demo_releases] == ["1.2.3"]
assert [item["version"] for item in other_releases] == ["2.0.0"]
def test_async_get_plugin_release_versions_coalesces_forced_repository_requests(self, monkeypatch):
"""
同一仓库的并发强制刷新共享一个请求任务,避免缓存失效瞬间放大 GitHub 请求。
"""
try:
from app.core.cache import async_fresh
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
payload = [
{
"tag_name": "DemoPlugin_v1.2.3",
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
},
{
"tag_name": "OtherPlugin_v2.0.0",
"assets": [{"name": "otherplugin_v2.0.0.zip", "id": 2}],
},
]
request_count = 0
async def fake_request(*_args, **_kwargs):
nonlocal request_count
request_count += 1
await asyncio.sleep(0.01)
return _FakeTextResponse(200, payload)
async def run_test():
helper = PluginHelper()
await helper.async_get_plugin_release_versions.cache_clear()
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
async with async_fresh(True):
return await asyncio.gather(
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL),
helper.async_get_plugin_release_versions("OtherPlugin", REPO_URL),
)
demo_releases, other_releases = asyncio.run(run_test())
assert request_count == 1
assert [item["version"] for item in demo_releases] == ["1.2.3"]
assert [item["version"] for item in other_releases] == ["2.0.0"]
def test_async_forced_release_refresh_does_not_reuse_normal_read_task(self, monkeypatch):
"""强刷等待在途普通读取后再请求,最终缓存必须保留强刷结果。"""
try:
from app.core.cache import async_fresh
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
old_payload = [{
"tag_name": "DemoPlugin_v1.2.2",
"assets": [{"name": "demoplugin_v1.2.2.zip", "id": 1}],
}]
fresh_payload = [{
"tag_name": "DemoPlugin_v1.2.3",
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 2}],
}]
first_request_started = asyncio.Event()
release_first_request = asyncio.Event()
request_count = 0
async def fake_request(*_args, **_kwargs):
nonlocal request_count
request_count += 1
if request_count == 1:
first_request_started.set()
await release_first_request.wait()
return _FakeTextResponse(200, old_payload)
return _FakeTextResponse(200, fresh_payload)
async def run_test():
helper = PluginHelper()
await helper.async_get_plugin_release_versions.cache_clear()
monkeypatch.setattr(helper, "_PluginHelper__async_request_with_fallback", fake_request)
normal_task = asyncio.create_task(
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
)
await first_request_started.wait()
async with async_fresh(True):
force_task = asyncio.create_task(
helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
)
await asyncio.sleep(0.01)
request_count_before_normal_finished = request_count
release_first_request.set()
normal_result, force_result = await asyncio.gather(normal_task, force_task)
cached_result = await helper.async_get_plugin_release_versions("DemoPlugin", REPO_URL)
return request_count_before_normal_finished, normal_result, force_result, cached_result
request_count_before_normal_finished, normal_result, force_result, cached_result = asyncio.run(run_test())
assert request_count_before_normal_finished == 1
assert [item["version"] for item in normal_result] == ["1.2.2"]
assert [item["version"] for item in force_result] == ["1.2.3"]
assert [item["version"] for item in cached_result] == ["1.2.3"]
assert request_count == 2
def test_failed_forced_release_refresh_preserves_cached_repository_payload(self, monkeypatch):
"""GitHub 强刷失败时不以空值覆盖该仓库已有 Release 缓存。"""
try:
from app.core.cache import fresh
from app.helper.plugin import PluginHelper
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
payload = [{
"tag_name": "DemoPlugin_v1.2.3",
"assets": [{"name": "demoplugin_v1.2.3.zip", "id": 1}],
}]
responses = [_FakeTextResponse(200, payload), None]
def fake_request(*_args, **_kwargs):
return responses.pop(0)
helper = PluginHelper()
helper.get_plugin_release_versions.cache_clear()
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", fake_request)
initial = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
with fresh(True):
failed_refresh = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
cached = helper.get_plugin_release_versions("DemoPlugin", REPO_URL)
assert [item["version"] for item in initial] == ["1.2.3"]
assert failed_refresh == []
assert [item["version"] for item in cached] == ["1.2.3"]
assert responses == []
def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
"""
全市场刷新不清理 Release 缓存Release 接口按请求仓库协调刷新两类数据。
"""
try:
from app.core.plugin import PluginManager
@@ -342,7 +514,56 @@ class TestPluginHelper:
PluginManager().get_online_plugins(force=True)
assert clear_calls == ["clear"]
assert clear_calls == []
def test_async_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch):
"""异步全市场刷新同样不得清理其他仓库的 Release 缓存。"""
try:
from app.core.plugin import PluginManager
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
clear_calls = []
async def fake_clear():
clear_calls.append("clear")
fake_release_method = SimpleNamespace(cache_clear=fake_clear)
fake_helper = SimpleNamespace(async_get_plugin_release_versions=fake_release_method)
async def fake_market(*_args, **_kwargs):
return []
monkeypatch.setattr("app.core.plugin.settings.PLUGIN_MARKET", "https://github.com/demo/plugins")
monkeypatch.setattr("app.core.plugin.PluginHelper", lambda: fake_helper)
monkeypatch.setattr(PluginManager, "async_get_plugins_from_market", fake_market)
asyncio.run(PluginManager().async_get_online_plugins(force=True))
assert clear_calls == []
def test_get_local_plugin_version_reads_only_requested_installed_plugin(self, monkeypatch):
"""单插件版本查询不构建全部本地插件信息。"""
try:
from app.core.plugin import PluginManager
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey
except ModuleNotFoundError as exc:
pytest.skip(f"missing dependency: {exc}")
class DemoPlugin:
plugin_version = "1.2.0"
plugin_manager = PluginManager()
monkeypatch.setattr(plugin_manager, "_plugins", {"DemoPlugin": DemoPlugin})
monkeypatch.setattr(
SystemConfigOper,
"get",
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
)
assert plugin_manager.get_local_plugin_version("DemoPlugin") == "1.2.0"
assert plugin_manager.get_local_plugin_version("OtherPlugin") is None
def test_annotate_plugin_system_version_marks_incompatible(self):
"""