mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 04:27:40 +08:00
fix(plugin): 收敛市场安装与运行态错误处理 (#6470)
This commit is contained in:
Vendored
+54
-35
@@ -861,7 +861,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
releases.extend(cls.__normalize_plugin_release_response(payload))
|
||||
return len(payload) >= 100
|
||||
|
||||
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
|
||||
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
|
||||
def get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
@@ -884,7 +884,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
def get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
@@ -892,16 +891,13 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
request = self._build_plugin_index_request(repo_url, package_version)
|
||||
if request is None:
|
||||
try:
|
||||
payload = self.get_plugin_index_result(repo_url, package_version)
|
||||
except (ValueError, RuntimeError):
|
||||
return None
|
||||
package_url, headers = request
|
||||
res = self.__request_with_fallback(package_url, headers=headers)
|
||||
if res is None:
|
||||
return None
|
||||
return self._resolve_plugin_index_response(res.status_code, res.text)
|
||||
return payload if payload is not None else {}
|
||||
|
||||
@cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases")
|
||||
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases")
|
||||
def _get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
|
||||
"""
|
||||
按仓库获取 GitHub Release 原始分页数据,供仓库内所有插件共享。
|
||||
@@ -1137,12 +1133,12 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return True, msg
|
||||
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
|
||||
self.__remove_old_plugin(pid)
|
||||
return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)
|
||||
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
|
||||
|
||||
return self.__install_flow_sync(pid, force_install, prepare_release, repo_url)
|
||||
# 未声明 release 打包的插件继续使用文件列表方式安装。
|
||||
def prepare_filelist() -> Tuple[bool, str]:
|
||||
return self.__prepare_content_via_filelist_sync(pid.lower(), user_repo, package_version)
|
||||
return self.__prepare_content_via_filelist_sync(pid, user_repo, package_version)
|
||||
|
||||
return self.__install_flow_sync(pid, force_install, prepare_filelist, repo_url)
|
||||
|
||||
@@ -1236,6 +1232,8 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
timeout=30)
|
||||
if res is None:
|
||||
return None, "连接仓库失败"
|
||||
elif res.status_code == 404:
|
||||
return None, "插件源码目录不存在"
|
||||
elif res.status_code != 200:
|
||||
return None, f"连接仓库失败:{res.status_code} - " \
|
||||
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.reason}"
|
||||
@@ -2385,8 +2383,12 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
timeout=30,
|
||||
is_api=True,
|
||||
)
|
||||
if rel_res is None or rel_res.status_code != 200:
|
||||
return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}"
|
||||
if rel_res is None:
|
||||
return False, "获取 Release 信息失败:连接失败"
|
||||
if rel_res.status_code == 404:
|
||||
return False, f"{release_tag} 插件发布包不存在"
|
||||
if rel_res.status_code != 200:
|
||||
return False, f"获取 Release 信息失败:{rel_res.status_code}"
|
||||
|
||||
try:
|
||||
rel_json = rel_res.json()
|
||||
@@ -2550,7 +2552,7 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
logger.error(f"[GitHub] 所有策略均请求失败,URL: {url},请检查网络连接或 GitHub 配置")
|
||||
return None
|
||||
|
||||
@cached(maxsize=128, ttl=1800) # type: ignore[misc] # 缓存装饰器暂未提供泛型签名
|
||||
@cached(maxsize=1024, ttl=1800, skip_none=False) # type: ignore[misc]
|
||||
async def async_get_plugin_index_result(
|
||||
self,
|
||||
repo_url: str,
|
||||
@@ -2576,7 +2578,6 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
raise RuntimeError("插件索引响应格式无效")
|
||||
return payload
|
||||
|
||||
@cached(maxsize=128, ttl=1800)
|
||||
async def async_get_plugins(self, repo_url: str,
|
||||
package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
@@ -2584,19 +2585,16 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
request = self._build_plugin_index_request(repo_url, package_version)
|
||||
if request is None:
|
||||
try:
|
||||
payload = await self.async_get_plugin_index_result(
|
||||
repo_url,
|
||||
package_version,
|
||||
)
|
||||
except (ValueError, RuntimeError):
|
||||
return None
|
||||
package_url, headers = request
|
||||
res = await self.__async_request_with_fallback(
|
||||
package_url,
|
||||
headers=headers,
|
||||
)
|
||||
if res is None:
|
||||
return None
|
||||
return self._resolve_plugin_index_response(res.status_code, res.text)
|
||||
return payload if payload is not None else {}
|
||||
|
||||
@cached(maxsize=32, ttl=1800, shared_key="get_plugin_repo_releases")
|
||||
@cached(maxsize=256, ttl=1800, shared_key="get_plugin_repo_releases")
|
||||
async def _async_get_plugin_repo_releases(self, repo_url: str) -> Optional[List[dict]]:
|
||||
"""
|
||||
异步按仓库获取 GitHub Release 原始分页数据。
|
||||
@@ -2725,6 +2723,8 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
timeout=30)
|
||||
if res is None:
|
||||
return None, "连接仓库失败"
|
||||
elif res.status_code == 404:
|
||||
return None, "插件源码目录不存在"
|
||||
elif res.status_code != 200:
|
||||
return None, f"连接仓库失败:{res.status_code} - " \
|
||||
f"{'超出速率限制,请设置Github Token或稍后重试' if res.status_code == 403 else res.text}"
|
||||
@@ -3287,12 +3287,12 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
return True, msg
|
||||
logger.warning(f"{pid} Release 安装失败,回退文件列表安装:{msg}")
|
||||
await self.__async_remove_old_plugin(pid)
|
||||
return await self.__prepare_content_via_filelist_async(pid.lower(), user_repo, package_version)
|
||||
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
|
||||
|
||||
return await self.__install_flow_async(pid, force_install, prepare_release, repo_url)
|
||||
# 未声明 release 打包的插件继续使用文件列表方式安装。
|
||||
async def prepare_filelist() -> Tuple[bool, str]:
|
||||
return await self.__prepare_content_via_filelist_async(pid.lower(), user_repo, package_version)
|
||||
return await self.__prepare_content_via_filelist_async(pid, user_repo, package_version)
|
||||
|
||||
return await self.__install_flow_async(pid, force_install, prepare_filelist, repo_url)
|
||||
|
||||
@@ -3362,10 +3362,13 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
同步准备插件内容,通过文件列表获取插件文件和依赖
|
||||
"""
|
||||
file_list, msg = self.__get_file_list(pid, user_repo, package_version)
|
||||
runtime_pid = pid.lower()
|
||||
file_list, msg = self.__get_file_list(runtime_pid, user_repo, package_version)
|
||||
if not file_list:
|
||||
if msg == "插件源码目录不存在":
|
||||
return False, f"{pid} {msg}"
|
||||
return False, msg
|
||||
ok, m = self.__download_files(pid, file_list, user_repo, package_version)
|
||||
ok, m = self.__download_files(runtime_pid, file_list, user_repo, package_version)
|
||||
if not ok:
|
||||
return False, m
|
||||
return True, ""
|
||||
@@ -3375,10 +3378,22 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
异步准备插件内容,通过文件列表获取插件文件和依赖
|
||||
"""
|
||||
file_list, msg = await self.__async_get_file_list(pid, user_repo, package_version)
|
||||
runtime_pid = pid.lower()
|
||||
file_list, msg = await self.__async_get_file_list(
|
||||
runtime_pid,
|
||||
user_repo,
|
||||
package_version,
|
||||
)
|
||||
if not file_list:
|
||||
if msg == "插件源码目录不存在":
|
||||
return False, f"{pid} {msg}"
|
||||
return False, msg
|
||||
ok, m = await self.__async_download_files(pid, file_list, user_repo, package_version)
|
||||
ok, m = await self.__async_download_files(
|
||||
runtime_pid,
|
||||
file_list,
|
||||
user_repo,
|
||||
package_version,
|
||||
)
|
||||
if not ok:
|
||||
return False, m
|
||||
return True, ""
|
||||
@@ -3399,8 +3414,12 @@ class PluginHelper(metaclass=WeakSingleton):
|
||||
timeout=30,
|
||||
is_api=True,
|
||||
)
|
||||
if rel_res is None or rel_res.status_code != 200:
|
||||
return False, f"获取 Release 信息失败:{rel_res.status_code if rel_res else '连接失败'}"
|
||||
if rel_res is None:
|
||||
return False, "获取 Release 信息失败:连接失败"
|
||||
if rel_res.status_code == 404:
|
||||
return False, f"{release_tag} 插件发布包不存在"
|
||||
if rel_res.status_code != 200:
|
||||
return False, f"获取 Release 信息失败:{rel_res.status_code}"
|
||||
|
||||
try:
|
||||
rel_json = rel_res.json()
|
||||
|
||||
@@ -23,11 +23,12 @@ from app.schemas.exception import (
|
||||
PersistenceUnavailableError,
|
||||
PluginMutationRejectedError,
|
||||
)
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
|
||||
InstalledPluginsReader = Callable[[], list[str]]
|
||||
PluginIdsProvider = Callable[[], list[str]]
|
||||
InstallReporter = Callable[[str, str | None], Awaitable[object]]
|
||||
PluginReloader = Callable[[str], Awaitable[object]]
|
||||
PluginReloader = Callable[[str], Awaitable[PluginRuntimeStatus]]
|
||||
PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
|
||||
PluginMutationAdmission = Callable[[str], ContextManager[None]]
|
||||
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
|
||||
@@ -380,7 +381,7 @@ class PluginInstallCommand:
|
||||
|
||||
state.stage = "runtime_reload"
|
||||
state.runtime_touched = True
|
||||
await self.__await_side_effect(self.__target_reloader(plugin_id))
|
||||
await self.__reload_active(plugin_id)
|
||||
state.stage = "registration_refresh"
|
||||
state.registrations_touched = True
|
||||
await self.__await_side_effect(
|
||||
@@ -511,7 +512,7 @@ class PluginInstallCommand:
|
||||
"""刷新已经提交且载荷事实未变化的插件运行态。"""
|
||||
failure_stage = "runtime_reload"
|
||||
try:
|
||||
await self.__await_side_effect(self.__target_reloader(plugin_id))
|
||||
await self.__reload_active(plugin_id)
|
||||
failure_stage = "registration_refresh"
|
||||
await self.__await_side_effect(
|
||||
self.__registration_refresher(plugin_id)
|
||||
@@ -542,6 +543,14 @@ class PluginInstallCommand:
|
||||
report_error=report_error,
|
||||
)
|
||||
|
||||
async def __reload_active(self, plugin_id: str) -> None:
|
||||
"""重载只有进入 ACTIVE 才能作为安装或刷新成功继续提交。"""
|
||||
runtime_status = await self.__await_side_effect(
|
||||
self.__target_reloader(plugin_id)
|
||||
)
|
||||
if runtime_status is not PluginRuntimeStatus.ACTIVE:
|
||||
raise RuntimeError("插件加载失败,请查看插件日志")
|
||||
|
||||
async def __finish_committed(self, state: _InstallState) -> str:
|
||||
"""幂等清理 COMMITTED 事务;失败时保留 journal 供启动回放。"""
|
||||
try:
|
||||
|
||||
@@ -53,7 +53,7 @@ class PluginCandidateInventoryReader:
|
||||
local_candidate_loader: LocalCandidateLoader | None = None,
|
||||
async_market_loader: AsyncMarketLoader | None = None,
|
||||
generations: Sequence[str] = PLUGIN_V3_GENERATIONS,
|
||||
max_concurrency: int = 12,
|
||||
max_concurrency: int = 24,
|
||||
) -> None:
|
||||
"""保存读取端口,并限制异步市场请求的进程内并发。"""
|
||||
normalized_generations = tuple(
|
||||
|
||||
@@ -689,7 +689,7 @@ def list_effective_online_candidates(
|
||||
plugin_id: str,
|
||||
generations: Sequence[str],
|
||||
) -> tuple[PluginMarketCandidate, ...]:
|
||||
"""按来源列出当前运行代际实际可安装的最高版本候选。"""
|
||||
"""按来源列出当前运行代际实际可安装的最高版本候选,官方来源始终置顶。"""
|
||||
generation_order = _normalize_generation_order(generations)
|
||||
grouped: dict[
|
||||
tuple[TrustedPluginSourceType, str],
|
||||
@@ -706,6 +706,10 @@ def list_effective_online_candidates(
|
||||
selected_candidate = _select_best(candidates, generation_order)
|
||||
if isinstance(selected_candidate, PluginMarketCandidate):
|
||||
selected.append(selected_candidate)
|
||||
selected.sort(
|
||||
key=lambda candidate: candidate.source_type
|
||||
is not TrustedPluginSourceType.OFFICIAL
|
||||
)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
|
||||
@@ -114,6 +114,13 @@ def run_api_server() -> None:
|
||||
host=get_runtime_setting('HOST'),
|
||||
port=get_runtime_setting('PORT'),
|
||||
reload=get_runtime_setting('DEV'),
|
||||
# 运行插件及其恢复材料由插件生命周期管理,不属于宿主源码变更。
|
||||
reload_excludes=[
|
||||
str(get_runtime_setting('ROOT_PATH') / "app" / "plugins"),
|
||||
str(get_runtime_setting('CONFIG_PATH')),
|
||||
]
|
||||
if get_runtime_setting('DEV')
|
||||
else None,
|
||||
workers=get_runtime_setting('API_WORKERS'),
|
||||
timeout_graceful_shutdown=60,
|
||||
)
|
||||
|
||||
@@ -108,15 +108,15 @@ class PluginLifecycle:
|
||||
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
|
||||
for plugin in plugins:
|
||||
current_id = plugin.__name__
|
||||
if plugin_id and current_id != plugin_id:
|
||||
if plugin_id and current_id.casefold() != plugin_id.casefold():
|
||||
continue
|
||||
try:
|
||||
if not self._auth_checker(plugin):
|
||||
if current_id in self._classes:
|
||||
self._classes[current_id] = plugin
|
||||
status = PluginRuntimeStatus.BLOCKED_BY_POLICY
|
||||
self._runtime_status_writer(current_id, status)
|
||||
results[current_id] = status
|
||||
self._runtime_status_writer(plugin_id or current_id, status)
|
||||
results[plugin_id or current_id] = status
|
||||
continue
|
||||
self._classes[current_id] = plugin
|
||||
instance = plugin()
|
||||
@@ -131,16 +131,19 @@ class PluginLifecycle:
|
||||
else:
|
||||
self._disable_events(plugin)
|
||||
status = PluginRuntimeStatus.ACTIVE
|
||||
self._runtime_status_writer(current_id, status)
|
||||
results[current_id] = status
|
||||
self._runtime_status_writer(plugin_id or current_id, status)
|
||||
results[plugin_id or current_id] = status
|
||||
except Exception as error: # noqa: BLE001
|
||||
status = PluginRuntimeStatus.LOAD_FAILED
|
||||
self._runtime_status_writer(current_id, status)
|
||||
results[current_id] = status
|
||||
self._runtime_status_writer(plugin_id or current_id, status)
|
||||
results[plugin_id or current_id] = status
|
||||
self._logger.error(
|
||||
f"加载插件 {current_id} 出错:{error} - {traceback.format_exc()}"
|
||||
)
|
||||
if plugin_id and plugin_id not in results:
|
||||
if plugin_id and not any(
|
||||
result_id.casefold() == plugin_id.casefold()
|
||||
for result_id in results
|
||||
):
|
||||
status = PluginRuntimeStatus.LOAD_FAILED
|
||||
self._runtime_status_writer(plugin_id, status)
|
||||
results[plugin_id] = status
|
||||
@@ -286,9 +289,10 @@ class PluginLifecycle:
|
||||
return False
|
||||
|
||||
if plugin_id:
|
||||
self._classes.pop(plugin_id, None)
|
||||
self._running.pop(plugin_id, None)
|
||||
self._quiesced_hooks.pop(plugin_id, None)
|
||||
runtime_id = self._resolve_runtime_id(plugin_id)
|
||||
self._classes.pop(runtime_id, None)
|
||||
self._running.pop(runtime_id, None)
|
||||
self._quiesced_hooks.pop(runtime_id, None)
|
||||
else:
|
||||
self._classes.clear()
|
||||
self._running.clear()
|
||||
@@ -300,14 +304,22 @@ class PluginLifecycle:
|
||||
"""返回本阶段处理的稳定实例快照,并保持旧停机日志语义。"""
|
||||
if plugin_id:
|
||||
self._logger.info(f"正在停止插件 {plugin_id}...")
|
||||
plugin = self._running.get(plugin_id)
|
||||
plugins = {plugin_id: plugin} if plugin else {}
|
||||
runtime_id = self._resolve_runtime_id(plugin_id)
|
||||
plugin = self._running.get(runtime_id)
|
||||
plugins = {runtime_id: plugin} if plugin else {}
|
||||
if not plugin:
|
||||
self._logger.debug(f"插件 {plugin_id} 不存在或未加载")
|
||||
return plugins
|
||||
self._logger.info("正在停止所有插件...")
|
||||
return dict(self._running)
|
||||
|
||||
def _resolve_runtime_id(self, plugin_id: str) -> str:
|
||||
"""按不区分大小写的插件 ID 找到运行时注册表键。"""
|
||||
for runtime_id in (*self._running, *self._classes):
|
||||
if runtime_id.casefold() == plugin_id.casefold():
|
||||
return runtime_id
|
||||
return plugin_id
|
||||
|
||||
def _is_quiesced(self, plugin_id: str, plugin: Any) -> bool:
|
||||
"""判断 handler 及当前实例声明的旧 ABI hooks 是否均已成功收敛。"""
|
||||
required = {self._EVENT_HANDLERS_QUIESCED} | {
|
||||
@@ -334,6 +346,11 @@ class PluginLifecycle:
|
||||
"""重启指定插件并返回本次加载结果。"""
|
||||
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
|
||||
self.stop(plugin_id)
|
||||
status = self.start(plugin_id)[plugin_id]
|
||||
results = self.start(plugin_id)
|
||||
status = next(
|
||||
status
|
||||
for result_id, status in results.items()
|
||||
if result_id.casefold() == plugin_id.casefold()
|
||||
)
|
||||
self._event_sender(reload_event, data={"plugin_id": plugin_id})
|
||||
return status
|
||||
|
||||
+3
-2
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6807,
|
||||
"edge_sha256": "93c39ca5828e23fcb5e4b33ffea101cb1ebc9a4ef6fac1317e2ebd482592ecdd",
|
||||
"edge_count": 6808,
|
||||
"edge_sha256": "bde5dc465241463f8695da3a7140dfed1f05757fd3f57493fa91d20fd80b6847",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -2788,6 +2788,7 @@
|
||||
"app.application.plugin.install -> app.runtime.log",
|
||||
"app.application.plugin.install -> app.schemas",
|
||||
"app.application.plugin.install -> app.schemas.exception",
|
||||
"app.application.plugin.install -> app.schemas.plugin",
|
||||
"app.application.plugin.inventory -> app.application",
|
||||
"app.application.plugin.inventory -> app.application.plugin",
|
||||
"app.application.plugin.inventory -> app.application.plugin.identity",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""插件市场候选库存读取测试。"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.identity import TrustedPluginSourceType
|
||||
@@ -288,3 +290,40 @@ async def test_async_loader_preserves_generation_facts() -> None:
|
||||
assert [read.package_generation for read in inventory.market_reads] == [
|
||||
"v3", "v2", "v1"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_inventory_bounds_large_market_working_set() -> None:
|
||||
"""大市场库存允许 24 个并发读取,但不能无界放大出站连接。"""
|
||||
active = 0
|
||||
max_active = 0
|
||||
first_batch_started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def loader(_market: str, _package_version: str | None, _force: bool):
|
||||
nonlocal active, max_active
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
if active == 24:
|
||||
first_batch_started.set()
|
||||
await release.wait()
|
||||
active -= 1
|
||||
return {}
|
||||
|
||||
markets = [
|
||||
f"https://github.com/concurrency-owner/repository-{index}"
|
||||
for index in range(10)
|
||||
]
|
||||
reader = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {},
|
||||
async_market_loader=loader,
|
||||
)
|
||||
inventory_task = asyncio.create_task(reader.async_load(markets))
|
||||
|
||||
await asyncio.wait_for(first_batch_started.wait(), timeout=1)
|
||||
assert active == 24
|
||||
release.set()
|
||||
inventory = await inventory_task
|
||||
|
||||
assert max_active == 24
|
||||
assert inventory.complete
|
||||
|
||||
+132
-16
@@ -94,6 +94,8 @@ class _FakeResponse:
|
||||
def __init__(self, status_code: int, payload: dict | None = None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
self.reason = self._payload.get("message", "")
|
||||
self.text = self.reason
|
||||
|
||||
def json(self):
|
||||
"""返回构造时注入的 JSON payload。"""
|
||||
@@ -2160,7 +2162,7 @@ demo = { index = "private" }
|
||||
|
||||
def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
|
||||
"""
|
||||
release 和文件列表都不可用时返回最终文件列表错误,并在每次写入前后保持目录可回滚。
|
||||
release 和源码目录都不存在时返回稳定业务错误,并在每次写入前后保持目录可回滚。
|
||||
"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
@@ -2172,14 +2174,14 @@ demo = { index = "private" }
|
||||
helper,
|
||||
monkeypatch,
|
||||
{"release": True, "version": "1.2.3"},
|
||||
(False, "未找到资产文件:demoplugin_v1.2.3.zip"),
|
||||
(False, "获取文件列表失败"),
|
||||
(False, "DemoPlugin_v1.2.3 插件发布包不存在"),
|
||||
(False, "DemoPlugin 插件源码目录不存在"),
|
||||
)
|
||||
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert not success
|
||||
assert "获取文件列表失败" == message
|
||||
assert "DemoPlugin 插件源码目录不存在" == message
|
||||
assert ["remove", "release", "remove", "filelist", "remove"] == calls
|
||||
|
||||
def test_install_uses_filelist_when_release_flag_is_disabled(self, monkeypatch):
|
||||
@@ -2602,7 +2604,7 @@ demo = { index = "private" }
|
||||
|
||||
def test_async_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
|
||||
"""
|
||||
异步安装路径在 release 与文件列表都失败时返回文件列表错误,并保持失败清理顺序稳定。
|
||||
异步安装路径在 release 与源码目录都不存在时返回稳定业务错误,并保持失败清理顺序稳定。
|
||||
"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
@@ -2614,8 +2616,8 @@ demo = { index = "private" }
|
||||
helper,
|
||||
monkeypatch,
|
||||
{"release": True, "version": "1.2.3"},
|
||||
(False, "未找到资产文件:demoplugin_v1.2.3.zip"),
|
||||
(False, "获取文件列表失败"),
|
||||
(False, "DemoPlugin_v1.2.3 插件发布包不存在"),
|
||||
(False, "DemoPlugin 插件源码目录不存在"),
|
||||
)
|
||||
|
||||
success, message = asyncio.run(
|
||||
@@ -2623,12 +2625,12 @@ demo = { index = "private" }
|
||||
)
|
||||
|
||||
assert not success
|
||||
assert "获取文件列表失败" == message
|
||||
assert "DemoPlugin 插件源码目录不存在" == message
|
||||
assert calls == ["remove", "release", "remove", "filelist", "remove"]
|
||||
|
||||
def test_async_install_release_fallback_uses_lowercase_filelist_pid(self, monkeypatch):
|
||||
def test_async_install_release_fallback_preserves_plugin_id(self, monkeypatch):
|
||||
"""
|
||||
异步 release 回退文件列表安装时使用小写插件 ID,保持 GitHub 目录查询与同步路径一致。
|
||||
异步 release 回退保留清单中的插件 ID,供文件列表层生成可读错误。
|
||||
"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
@@ -2657,11 +2659,11 @@ demo = { index = "private" }
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert ["demoplugin"] == filelist_pids
|
||||
assert ["DemoPlugin"] == filelist_pids
|
||||
|
||||
def test_async_install_non_release_uses_lowercase_filelist_pid(self, monkeypatch):
|
||||
def test_async_install_non_release_preserves_plugin_id(self, monkeypatch):
|
||||
"""
|
||||
异步文件列表直装使用小写插件 ID,避免大小写插件 ID 影响远端目录匹配。
|
||||
异步文件列表直装保留清单中的插件 ID,路径规范化由文件列表层负责。
|
||||
"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
@@ -2690,7 +2692,7 @@ demo = { index = "private" }
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert ["demoplugin"] == filelist_pids
|
||||
assert ["DemoPlugin"] == filelist_pids
|
||||
|
||||
def test_install_from_release_reports_missing_tag(self, monkeypatch):
|
||||
"""
|
||||
@@ -2707,7 +2709,30 @@ demo = { index = "private" }
|
||||
success, message = helper._PluginHelper__install_from_release(PLUGIN_ID, "demo/repo", "DemoPlugin_v1.2.3")
|
||||
|
||||
assert not success
|
||||
assert "获取 Release 信息失败:404" == message
|
||||
assert "DemoPlugin_v1.2.3 插件发布包不存在" == message
|
||||
|
||||
def test_get_file_list_reports_missing_plugin_directory(self, monkeypatch):
|
||||
"""索引存在但源码目录缺失时不向用户暴露底层 HTTP 404。"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
helper = PluginHelper()
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__request_with_fallback",
|
||||
lambda *_args, **_kwargs: _FakeResponse(404),
|
||||
)
|
||||
|
||||
file_list, message = helper._PluginHelper__get_file_list(
|
||||
PLUGIN_ID,
|
||||
"demo/repo",
|
||||
"v2",
|
||||
)
|
||||
|
||||
assert file_list is None
|
||||
assert message == "插件源码目录不存在"
|
||||
|
||||
def test_install_from_release_reports_missing_asset(self, monkeypatch):
|
||||
"""
|
||||
@@ -3157,6 +3182,36 @@ demo = { index = "private" }
|
||||
assert not success
|
||||
assert "list failed" == message
|
||||
|
||||
def test_prepare_content_via_filelist_sync_names_missing_plugin(self, monkeypatch):
|
||||
"""同步文件列表层规范化路径 ID,并在源码目录缺失时保留原插件 ID。"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
helper = PluginHelper()
|
||||
requested_ids = []
|
||||
|
||||
def fake_file_list(pid, *_args):
|
||||
requested_ids.append(pid)
|
||||
return None, "插件源码目录不存在"
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__get_file_list",
|
||||
fake_file_list,
|
||||
)
|
||||
|
||||
success, message = helper._PluginHelper__prepare_content_via_filelist_sync(
|
||||
PLUGIN_ID,
|
||||
"demo/repo",
|
||||
"v2",
|
||||
)
|
||||
|
||||
assert not success
|
||||
assert message == "DemoPlugin 插件源码目录不存在"
|
||||
assert requested_ids == ["demoplugin"]
|
||||
|
||||
def test_prepare_content_via_filelist_sync_returns_download_error(self, monkeypatch):
|
||||
"""
|
||||
文件列表存在但文件下载失败时向上返回下载错误。
|
||||
@@ -3231,6 +3286,38 @@ demo = { index = "private" }
|
||||
assert not success
|
||||
assert "list failed" == message
|
||||
|
||||
def test_async_prepare_content_via_filelist_names_missing_plugin(self, monkeypatch):
|
||||
"""异步文件列表层规范化路径 ID,并在源码目录缺失时保留原插件 ID。"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
helper = PluginHelper()
|
||||
requested_ids = []
|
||||
|
||||
async def fake_file_list(pid, *_args):
|
||||
requested_ids.append(pid)
|
||||
return None, "插件源码目录不存在"
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__async_get_file_list",
|
||||
fake_file_list,
|
||||
)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper._PluginHelper__prepare_content_via_filelist_async(
|
||||
PLUGIN_ID,
|
||||
"demo/repo",
|
||||
"v2",
|
||||
)
|
||||
)
|
||||
|
||||
assert not success
|
||||
assert message == "DemoPlugin 插件源码目录不存在"
|
||||
assert requested_ids == ["demoplugin"]
|
||||
|
||||
def test_async_prepare_content_via_filelist_returns_download_error(self, monkeypatch):
|
||||
"""
|
||||
异步文件列表下载失败时向上返回下载错误。
|
||||
@@ -3374,7 +3461,36 @@ demo = { index = "private" }
|
||||
)
|
||||
|
||||
assert not success
|
||||
assert "获取 Release 信息失败:404" == message
|
||||
assert "DemoPlugin_v1.2.3 插件发布包不存在" == message
|
||||
|
||||
def test_async_get_file_list_reports_missing_plugin_directory(self, monkeypatch):
|
||||
"""异步源码目录读取将 HTTP 404 收敛为稳定业务语义。"""
|
||||
try:
|
||||
from app.adapters.external.market import PluginHelper
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
helper = PluginHelper()
|
||||
|
||||
async def fake_request(*_args, **_kwargs):
|
||||
return _FakeResponse(404)
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__async_request_with_fallback",
|
||||
fake_request,
|
||||
)
|
||||
|
||||
file_list, message = asyncio.run(
|
||||
helper._PluginHelper__async_get_file_list(
|
||||
PLUGIN_ID,
|
||||
"demo/repo",
|
||||
"v2",
|
||||
)
|
||||
)
|
||||
|
||||
assert file_list is None
|
||||
assert message == "插件源码目录不存在"
|
||||
|
||||
def test_async_install_from_release_reports_missing_asset_id(self, monkeypatch):
|
||||
"""
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.schemas.exception import (
|
||||
PersistenceUnavailableError,
|
||||
PluginMutationRejectedError,
|
||||
)
|
||||
from app.schemas.plugin import PluginRuntimeStatus
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
@@ -299,7 +300,8 @@ def _command(
|
||||
plugin_ids_provider=lambda: plugin_ids or [],
|
||||
packages=packages,
|
||||
install_reporter=reporter or default_reporter,
|
||||
target_reloader=target_reloader or AsyncMock(),
|
||||
target_reloader=target_reloader
|
||||
or AsyncMock(return_value=PluginRuntimeStatus.ACTIVE),
|
||||
rollback_reloader=rollback_reloader or AsyncMock(),
|
||||
registration_refresher=registration_refresher or AsyncMock(),
|
||||
mutation=mutation or (lambda _operation: nullcontext()),
|
||||
@@ -361,6 +363,10 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
|
||||
|
||||
return action
|
||||
|
||||
async def target_reload(_plugin_id):
|
||||
calls.append("target_reload")
|
||||
return PluginRuntimeStatus.ACTIVE
|
||||
|
||||
async def installer(**_kwargs):
|
||||
calls.append("package")
|
||||
return True, "installed"
|
||||
@@ -380,7 +386,7 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
|
||||
payload_receipt=receipt,
|
||||
package_stage_backup=mark("stage_backup"),
|
||||
package_activate_backup=mark("activate_backup"),
|
||||
target_reloader=mark("target_reload"),
|
||||
target_reloader=target_reload,
|
||||
registration_refresher=mark("registrations"),
|
||||
package_finalize_backup=mark("finalize_backup"),
|
||||
package_commit=mark("package_commit"),
|
||||
@@ -414,6 +420,67 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
|
||||
assert persistence.records == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_active_runtime_status_compensates_before_database_commit():
|
||||
"""运行态重载未激活时,安装必须在数据库提交前补偿并返回失败。"""
|
||||
package_restore = AsyncMock()
|
||||
package_cleanup = AsyncMock()
|
||||
rollback_reloader = AsyncMock(return_value=PluginRuntimeStatus.ACTIVE)
|
||||
registration_refresher = AsyncMock()
|
||||
target_reloader = AsyncMock(return_value=PluginRuntimeStatus.LOAD_FAILED)
|
||||
reporter = AsyncMock()
|
||||
|
||||
command, persistence, calls = _command(
|
||||
package_restore=package_restore,
|
||||
package_cleanup=package_cleanup,
|
||||
target_reloader=target_reloader,
|
||||
rollback_reloader=rollback_reloader,
|
||||
registration_refresher=registration_refresher,
|
||||
reporter=reporter,
|
||||
)
|
||||
|
||||
result = await _execute(command)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "runtime_reload"
|
||||
assert result.package_installed is True
|
||||
assert result.installed_list_persisted is False
|
||||
assert result.runtime_reloaded is False
|
||||
assert result.registrations_refreshed is False
|
||||
assert result.reported is False
|
||||
package_restore.assert_awaited_once()
|
||||
rollback_reloader.assert_awaited_once_with("DemoPlugin")
|
||||
registration_refresher.assert_awaited_once_with("DemoPlugin")
|
||||
reporter.assert_not_awaited()
|
||||
assert "journal_commit" not in calls
|
||||
assert persistence.records == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_plugin_with_non_active_runtime_status_is_not_reported_successfully():
|
||||
"""已有载荷刷新失败时不得伪装成运行态成功或发送安装上报。"""
|
||||
target_reloader = AsyncMock(return_value=PluginRuntimeStatus.LOAD_FAILED)
|
||||
reporter = AsyncMock()
|
||||
command, persistence, _ = _command(
|
||||
installed=["DemoPlugin"],
|
||||
plugin_ids=["DemoPlugin"],
|
||||
target_reloader=target_reloader,
|
||||
reporter=reporter,
|
||||
)
|
||||
|
||||
result = await _execute(command, admission=_admission(identity=_identity()))
|
||||
|
||||
assert result.success is False
|
||||
assert result.refreshed_only is True
|
||||
assert result.failure_stage == "runtime_reload"
|
||||
assert result.runtime_reloaded is False
|
||||
assert result.registrations_refreshed is False
|
||||
assert result.reported is False
|
||||
target_reloader.assert_awaited_once_with("DemoPlugin")
|
||||
reporter.assert_not_awaited()
|
||||
assert persistence.records == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"phase",
|
||||
@@ -726,7 +793,7 @@ async def test_existing_matching_payload_only_refreshes_runtime():
|
||||
"""同一来源、代际和版本已提交时只刷新运行态,不重复写包或 journal。"""
|
||||
checkpointer = AsyncMock()
|
||||
installer = AsyncMock()
|
||||
reloader = AsyncMock()
|
||||
reloader = AsyncMock(return_value=PluginRuntimeStatus.ACTIVE)
|
||||
refresher = AsyncMock()
|
||||
reporter = AsyncMock(return_value=True)
|
||||
command, persistence, _ = _command(
|
||||
|
||||
@@ -213,13 +213,13 @@ async def test_gateway_source_inspection_preserves_sources_and_hides_local_path(
|
||||
)
|
||||
inventory = CandidateInventory(
|
||||
(
|
||||
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
|
||||
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
|
||||
MarketRead.present(
|
||||
third_party.repo_url,
|
||||
(third_party,),
|
||||
package_generation="v3",
|
||||
),
|
||||
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
|
||||
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
|
||||
),
|
||||
(local,),
|
||||
local_read=LocalCandidateRead.present((local,)),
|
||||
|
||||
@@ -61,6 +61,20 @@ def test_lifecycle_records_active_result():
|
||||
assert statuses["DemoPlugin"] is PluginRuntimeStatus.ACTIVE
|
||||
|
||||
|
||||
def test_targeted_lifecycle_accepts_case_insensitive_directory_id():
|
||||
"""市场目录 ID 通常为小写,不能因插件类名大小写不同而误报加载失败。"""
|
||||
lifecycle, classes, running, statuses = _lifecycle(
|
||||
plugins=[_plugin_class()],
|
||||
)
|
||||
|
||||
result = lifecycle.start("demoplugin")
|
||||
|
||||
assert result == {"demoplugin": PluginRuntimeStatus.ACTIVE}
|
||||
assert "DemoPlugin" in classes
|
||||
assert "DemoPlugin" in running
|
||||
assert statuses["demoplugin"] is PluginRuntimeStatus.ACTIVE
|
||||
|
||||
|
||||
def test_lifecycle_records_policy_block_without_runtime_instance():
|
||||
"""类已发现但权限策略拒绝时进入 blocked_by_policy。"""
|
||||
lifecycle, _classes, running, statuses = _lifecycle(
|
||||
|
||||
@@ -43,11 +43,11 @@ async def test_sync_and_async_plugin_indexes_share_request_and_result(
|
||||
async_request,
|
||||
)
|
||||
|
||||
helper.get_plugins.cache_clear()
|
||||
await helper.async_get_plugins.cache_clear()
|
||||
helper.get_plugin_index_result.cache_clear()
|
||||
await helper.async_get_plugin_index_result.cache_clear()
|
||||
sync_result = helper.get_plugins(repo_url, "v3")
|
||||
# 同步与异步装饰器按设计共享缓存区;清除后再验证异步 I/O 入口本身。
|
||||
await helper.async_get_plugins.cache_clear()
|
||||
await helper.async_get_plugin_index_result.cache_clear()
|
||||
async_result = await helper.async_get_plugins(repo_url, "v3")
|
||||
|
||||
assert sync_result == async_result == {
|
||||
@@ -60,6 +60,174 @@ async def test_sync_and_async_plugin_indexes_share_request_and_result(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_read_warms_source_inventory_cache(monkeypatch) -> None:
|
||||
"""市场目录成功读取后,来源库存不得再次请求同一仓库代际。"""
|
||||
helper = PluginHelper()
|
||||
repo_url = "https://github.com/policy-owner/shared-index-cache"
|
||||
requests = 0
|
||||
|
||||
async def request(_url: str, *, headers: dict):
|
||||
nonlocal requests
|
||||
requests += 1
|
||||
return SimpleNamespace(
|
||||
status_code=200,
|
||||
text='{"DemoPlugin": {"version": "1.2.3"}}',
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__async_request_with_fallback",
|
||||
request,
|
||||
)
|
||||
await helper.async_get_plugin_index_result.cache_clear()
|
||||
|
||||
market_result = await helper.async_get_plugins(repo_url, "v3")
|
||||
inventory_result = await helper.async_get_plugin_index_result(repo_url, "v3")
|
||||
|
||||
assert market_result == inventory_result
|
||||
assert requests == 1
|
||||
|
||||
|
||||
def test_sync_force_refresh_bypasses_index_cache(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""同步强刷必须绕过唯一索引缓存。"""
|
||||
helper = PluginHelper()
|
||||
client = PluginMarketClient(helper)
|
||||
repo_url = "https://github.com/policy-owner/sync-force-refresh"
|
||||
version = "1.0.0"
|
||||
requests = 0
|
||||
|
||||
def request(_url: str, *, headers: dict):
|
||||
nonlocal requests
|
||||
requests += 1
|
||||
return SimpleNamespace(
|
||||
status_code=200,
|
||||
text=f'{{"DemoPlugin": {{"version": "{version}"}}}}',
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__request_with_fallback",
|
||||
request,
|
||||
)
|
||||
helper.get_plugin_index_result.cache_clear()
|
||||
|
||||
assert client.get_plugins(repo_url, "v3") == {
|
||||
"DemoPlugin": {"version": "1.0.0"},
|
||||
}
|
||||
assert client.get_plugins(repo_url, "v3") == {
|
||||
"DemoPlugin": {"version": "1.0.0"},
|
||||
}
|
||||
assert requests == 1
|
||||
|
||||
version = "2.0.0"
|
||||
assert client.get_plugins(repo_url, "v3", force=True) == {
|
||||
"DemoPlugin": {"version": "2.0.0"},
|
||||
}
|
||||
assert requests == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_force_refresh_bypasses_index_cache(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""异步强刷必须绕过唯一索引缓存。"""
|
||||
helper = PluginHelper()
|
||||
client = PluginMarketClient(helper)
|
||||
repo_url = "https://github.com/policy-owner/async-force-refresh"
|
||||
version = "1.0.0"
|
||||
requests = 0
|
||||
|
||||
async def request(_url: str, *, headers: dict):
|
||||
nonlocal requests
|
||||
requests += 1
|
||||
return SimpleNamespace(
|
||||
status_code=200,
|
||||
text=f'{{"DemoPlugin": {{"version": "{version}"}}}}',
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__async_request_with_fallback",
|
||||
request,
|
||||
)
|
||||
await helper.async_get_plugin_index_result.cache_clear()
|
||||
|
||||
assert await client.async_get_plugins(repo_url, "v3") == {
|
||||
"DemoPlugin": {"version": "1.0.0"},
|
||||
}
|
||||
assert await client.async_get_plugins(repo_url, "v3") == {
|
||||
"DemoPlugin": {"version": "1.0.0"},
|
||||
}
|
||||
assert requests == 1
|
||||
|
||||
version = "2.0.0"
|
||||
assert await client.async_get_plugins(repo_url, "v3", force=True) == {
|
||||
"DemoPlugin": {"version": "2.0.0"},
|
||||
}
|
||||
assert requests == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absent_plugin_generation_is_cached(monkeypatch) -> None:
|
||||
"""明确不存在的代际是稳定事实,后续来源检查不得重复请求。"""
|
||||
helper = PluginHelper()
|
||||
repo_url = "https://github.com/policy-owner/absent-index-cache"
|
||||
requests = 0
|
||||
|
||||
async def request(_url: str, *, headers: dict):
|
||||
nonlocal requests
|
||||
requests += 1
|
||||
return SimpleNamespace(status_code=404, text="404: Not Found")
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__async_request_with_fallback",
|
||||
request,
|
||||
)
|
||||
await helper.async_get_plugin_index_result.cache_clear()
|
||||
|
||||
first = await helper.async_get_plugin_index_result(repo_url, "v2")
|
||||
second = await helper.async_get_plugin_index_result(repo_url, "v2")
|
||||
|
||||
assert first is second is None
|
||||
assert requests == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_cache_retains_multi_market_generation_working_set(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""数十个市场的多代索引不能因缓存容量不足立即重复出站。"""
|
||||
helper = PluginHelper()
|
||||
requests = 0
|
||||
|
||||
async def request(_url: str, *, headers: dict):
|
||||
nonlocal requests
|
||||
requests += 1
|
||||
return SimpleNamespace(status_code=200, text='{"DemoPlugin": {}}')
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__async_request_with_fallback",
|
||||
request,
|
||||
)
|
||||
await helper.async_get_plugin_index_result.cache_clear()
|
||||
targets = [
|
||||
(f"https://github.com/cache-owner/repository-{index}", generation)
|
||||
for index in range(100)
|
||||
for generation in ("v3", "v2", None)
|
||||
]
|
||||
|
||||
for repo_url, generation in targets:
|
||||
await helper.async_get_plugin_index_result(repo_url, generation)
|
||||
await helper.async_get_plugin_index_result(*targets[0])
|
||||
|
||||
assert requests == len(targets)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "content", "expected"),
|
||||
[
|
||||
|
||||
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from uvicorn import Config
|
||||
from uvicorn.supervisors.watchfilesreload import FileFilter
|
||||
|
||||
from app import factory, main
|
||||
from app.runtime.topology import UnsupportedProcessTopologyError
|
||||
@@ -60,11 +62,47 @@ def test_development_reload_uses_import_string_factory(monkeypatch):
|
||||
host=main.settings.HOST,
|
||||
port=main.settings.PORT,
|
||||
reload=True,
|
||||
reload_excludes=[
|
||||
str(main.settings.ROOT_PATH / "app" / "plugins"),
|
||||
str(main.settings.CONFIG_PATH),
|
||||
],
|
||||
workers=1,
|
||||
timeout_graceful_shutdown=60,
|
||||
)
|
||||
|
||||
|
||||
def test_development_reload_ignores_plugin_runtime_state(monkeypatch):
|
||||
"""插件载荷与恢复快照由热加载管理,仅宿主源码变更触发进程重载。"""
|
||||
root_path = main.settings.ROOT_PATH
|
||||
plugin_path = root_path / "app" / "plugins"
|
||||
config_path = main.settings.CONFIG_PATH
|
||||
assert plugin_path.is_dir()
|
||||
assert config_path.is_dir()
|
||||
monkeypatch.setattr(main.settings, "DEV", True)
|
||||
monkeypatch.setattr(main.settings, "API_WORKERS", 1)
|
||||
uvicorn_run = MagicMock()
|
||||
monkeypatch.setattr(main.uvicorn, "run", uvicorn_run)
|
||||
|
||||
main.run_api_server()
|
||||
|
||||
reload_excludes = uvicorn_run.call_args.kwargs["reload_excludes"]
|
||||
reload_filter = FileFilter(
|
||||
Config(main.APP_FACTORY, reload=True, reload_excludes=reload_excludes)
|
||||
)
|
||||
|
||||
assert reload_filter(plugin_path / "autosignin" / "__init__.py") is False
|
||||
assert reload_filter(
|
||||
config_path
|
||||
/ "plugins_backup"
|
||||
/ ".autosignin.staging-transaction"
|
||||
/ "__init__.py"
|
||||
) is False
|
||||
assert reload_filter(
|
||||
config_path / "plugin_transactions" / "transaction" / "package" / "__init__.py"
|
||||
) is False
|
||||
assert reload_filter(root_path / "app" / "main.py") is True
|
||||
|
||||
|
||||
def test_safe_mode_multi_worker_uses_import_string_factory(monkeypatch):
|
||||
"""安全模式多 worker 由 Uvicorn supervisor 创建独立 ASGI factory 实例。"""
|
||||
monkeypatch.setattr(main.settings, "DEV", False)
|
||||
|
||||
Reference in New Issue
Block a user