mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
135 lines
4.1 KiB
Python
135 lines
4.1 KiB
Python
"""
|
||
插件重载与路由移除后,旧插件代码不得被第三方进程级缓存留在内存中。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import gc
|
||
import sys
|
||
import types
|
||
import typing
|
||
import weakref
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.adapters.web.plugin.routes import FastAPIDynamicRouteRegistry
|
||
from app.runtime.extensions.plugin.loader import PluginLoader
|
||
|
||
_SILENT_LOG = SimpleNamespace(
|
||
debug=lambda *_args: None,
|
||
info=lambda *_args: None,
|
||
warning=lambda *_args: None,
|
||
error=lambda *_args: None,
|
||
)
|
||
|
||
|
||
def _install_plugin_module(name: str, source: str) -> types.ModuleType:
|
||
"""
|
||
以插件运行时命名空间登记一个内存模块,模拟已导入的插件源码。
|
||
"""
|
||
module = types.ModuleType(name)
|
||
sys.modules[name] = module
|
||
exec(compile(source, f"<{name}>", "exec"), module.__dict__)
|
||
return module
|
||
|
||
|
||
def test_removed_plugin_route_releases_endpoint_from_fastapi_caches() -> None:
|
||
"""
|
||
插件路由移除后,FastAPI 可调用分类缓存不再持有旧端点及其模块。
|
||
"""
|
||
module = _install_plugin_module(
|
||
"app.plugins.routecachedemo",
|
||
"async def health():\n return {'ok': True}\n",
|
||
)
|
||
apis: list[dict] = [
|
||
{
|
||
"path": "/RouteCacheDemo/health",
|
||
"endpoint": module.health,
|
||
"methods": ["GET"],
|
||
"allow_anonymous": True,
|
||
}
|
||
]
|
||
app = FastAPI()
|
||
registry = FastAPIDynamicRouteRegistry(
|
||
app=app,
|
||
plugin_ids=lambda: ["RouteCacheDemo"],
|
||
plugin_apis=lambda _plugin_id: apis,
|
||
verify_token=lambda: None,
|
||
verify_apikey=lambda: None,
|
||
prefix="/api/v1/plugin",
|
||
log=_SILENT_LOG,
|
||
)
|
||
registry.update("RouteCacheDemo", "add")
|
||
# 真实请求让 FastAPI 把端点写入分类缓存
|
||
with TestClient(app) as client:
|
||
assert client.get("/api/v1/plugin/RouteCacheDemo/health").json() == {"ok": True}
|
||
|
||
endpoint_ref = weakref.ref(module.health)
|
||
apis.clear()
|
||
sys.modules.pop(module.__name__)
|
||
del module
|
||
registry.update("RouteCacheDemo", "remove")
|
||
gc.collect()
|
||
|
||
assert endpoint_ref() is None
|
||
|
||
|
||
def test_clear_modules_releases_plugin_classes_from_type_caches(tmp_path: Path) -> None:
|
||
"""
|
||
清除插件模块缓存后,Pydantic 泛型缓存与 typing 缓存不再持有旧插件类。
|
||
"""
|
||
module = _install_plugin_module(
|
||
"app.plugins.typecachedemo",
|
||
"from typing import Optional\n"
|
||
"from pydantic import BaseModel, RootModel\n"
|
||
"class Payload(BaseModel):\n"
|
||
" title: str\n"
|
||
"PayloadList = RootModel[list[Payload]]\n"
|
||
"MaybePayload = Optional[Payload]\n",
|
||
)
|
||
# 对照:其他模块的参数化结果不应被清掉,类身份保持不变
|
||
host_model = _install_plugin_module(
|
||
"app.schemas.typecachehost",
|
||
"from pydantic import BaseModel, RootModel\n"
|
||
"class HostItem(BaseModel):\n"
|
||
" name: str\n",
|
||
)
|
||
from pydantic import RootModel
|
||
|
||
host_list = RootModel[list[host_model.HostItem]]
|
||
payload_ref = weakref.ref(module.Payload)
|
||
loader = PluginLoader(
|
||
plugins_root=tmp_path,
|
||
import_preparer=lambda *_args, **_kwargs: None,
|
||
import_scanner=lambda *_args, **_kwargs: None,
|
||
log=_SILENT_LOG,
|
||
)
|
||
|
||
removed = loader.clear_modules("TypeCacheDemo")
|
||
del module
|
||
gc.collect()
|
||
|
||
assert removed == ["app.plugins.typecachedemo"]
|
||
assert payload_ref() is None
|
||
assert RootModel[list[host_model.HostItem]] is host_list
|
||
sys.modules.pop(host_model.__name__)
|
||
|
||
|
||
def test_clear_modules_without_matching_modules_keeps_type_caches(tmp_path: Path) -> None:
|
||
"""
|
||
没有可清除的插件模块时不触碰全局类型缓存。
|
||
"""
|
||
cached = typing.Optional[int]
|
||
loader = PluginLoader(
|
||
plugins_root=tmp_path,
|
||
import_preparer=lambda *_args, **_kwargs: None,
|
||
import_scanner=lambda *_args, **_kwargs: None,
|
||
log=_SILENT_LOG,
|
||
)
|
||
|
||
assert loader.clear_modules("NotLoadedPlugin") == []
|
||
assert typing.Optional[int] is cached
|