mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
fix(runtime): enforce canonical capability entrypoints (#6338)
This commit is contained in:
@@ -433,8 +433,16 @@ class CapabilityRuntime:
|
|||||||
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
|
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
|
||||||
module = sys.modules.get(module_name)
|
module = sys.modules.get(module_name)
|
||||||
namespace = getattr(module, "__dict__", None) if module is not None else None
|
namespace = getattr(module, "__dict__", None) if module is not None else None
|
||||||
if not isinstance(namespace, dict) or symbol_name not in namespace:
|
if not isinstance(namespace, dict):
|
||||||
return implementation
|
raise CapabilityAdapterContractError(
|
||||||
|
f"adapter 返回 {spec.id} 的 implementation 后,"
|
||||||
|
f"canonical 模块 {module_name} 未加载"
|
||||||
|
)
|
||||||
|
if symbol_name not in namespace:
|
||||||
|
raise CapabilityAdapterContractError(
|
||||||
|
f"adapter 返回 {spec.id} 的 implementation 后,"
|
||||||
|
f"canonical 符号 {spec.entrypoint} 不存在"
|
||||||
|
)
|
||||||
canonical = namespace[symbol_name]
|
canonical = namespace[symbol_name]
|
||||||
if implementation is not canonical:
|
if implementation is not canonical:
|
||||||
raise CapabilityAdapterContractError(
|
raise CapabilityAdapterContractError(
|
||||||
@@ -579,8 +587,8 @@ class CapabilityRuntime:
|
|||||||
state.pending_cleanup_error = pending_error
|
state.pending_cleanup_error = pending_error
|
||||||
raise
|
raise
|
||||||
if implementation is None:
|
if implementation is None:
|
||||||
implementation = self._sync_callback(adapter, "materialize", state.spec)
|
materialized = self._sync_callback(adapter, "materialize", state.spec)
|
||||||
implementation = self._canonical_implementation(state.spec, implementation)
|
implementation = self._canonical_implementation(state.spec, materialized)
|
||||||
candidate = self._sync_callback(
|
candidate = self._sync_callback(
|
||||||
adapter,
|
adapter,
|
||||||
"create",
|
"create",
|
||||||
@@ -766,8 +774,8 @@ class CapabilityRuntime:
|
|||||||
state.pending_cleanup_error = pending_error
|
state.pending_cleanup_error = pending_error
|
||||||
raise
|
raise
|
||||||
if implementation is None:
|
if implementation is None:
|
||||||
implementation = await self._async_callback(adapter, "materialize", state.spec)
|
materialized = await self._async_callback(adapter, "materialize", state.spec)
|
||||||
implementation = self._canonical_implementation(state.spec, implementation)
|
implementation = self._canonical_implementation(state.spec, materialized)
|
||||||
candidate = await self._async_callback(
|
candidate = await self._async_callback(
|
||||||
adapter,
|
adapter,
|
||||||
"create",
|
"create",
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -62,6 +63,32 @@ class _Candidate:
|
|||||||
stopped: bool = False
|
stopped: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class _SampleCapability:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_sample_entrypoints() -> Iterator[None]:
|
||||||
|
"""隔离合成 adapter 在 sys.modules 中公开的 canonical entrypoint。"""
|
||||||
|
module_names = ("sample_implementation", "other_implementation")
|
||||||
|
previous = {name: sys.modules[name] for name in module_names if name in sys.modules}
|
||||||
|
for name in module_names:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
yield
|
||||||
|
for name in module_names:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
if name in previous:
|
||||||
|
sys.modules[name] = previous[name]
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_sample(spec) -> type[_SampleCapability]:
|
||||||
|
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
|
||||||
|
module = types.ModuleType(module_name)
|
||||||
|
setattr(module, symbol_name, _SampleCapability)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
return _SampleCapability
|
||||||
|
|
||||||
|
|
||||||
class _SyncAdapter:
|
class _SyncAdapter:
|
||||||
execution_mode = AdapterExecutionMode.SYNC
|
execution_mode = AdapterExecutionMode.SYNC
|
||||||
|
|
||||||
@@ -84,7 +111,7 @@ class _SyncAdapter:
|
|||||||
self.materialize_calls += 1
|
self.materialize_calls += 1
|
||||||
if self.fail_materialize:
|
if self.fail_materialize:
|
||||||
raise RuntimeError("materialize failed")
|
raise RuntimeError("materialize failed")
|
||||||
return object()
|
return _materialize_sample(spec)
|
||||||
|
|
||||||
def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
|
def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
|
||||||
self.create_calls += 1
|
self.create_calls += 1
|
||||||
@@ -139,7 +166,7 @@ class _AsyncAdapter:
|
|||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
if self.fail_materialize:
|
if self.fail_materialize:
|
||||||
raise RuntimeError("async materialize failed")
|
raise RuntimeError("async materialize failed")
|
||||||
return object()
|
return _materialize_sample(spec)
|
||||||
|
|
||||||
async def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
|
async def create(self, spec, implementation: object, generation: int, previous: Any = None) -> _Candidate:
|
||||||
self.create_calls += 1
|
self.create_calls += 1
|
||||||
@@ -210,6 +237,107 @@ def test_materialize_failure_does_not_claim_resource_lifecycle_failure(tmp_path:
|
|||||||
assert snapshot.visible is False
|
assert snapshot.visible is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_rejects_missing_canonical_module(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""adapter 未加载 manifest 指定模块时不得发布替代实现。"""
|
||||||
|
adapter = _SyncAdapter()
|
||||||
|
runtime = CapabilityRuntime(
|
||||||
|
_registry(tmp_path),
|
||||||
|
adapters={"sample": adapter},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises(
|
||||||
|
CapabilityOperationError,
|
||||||
|
match="canonical 模块.*未加载",
|
||||||
|
):
|
||||||
|
runtime.materialize("sample.capability", reason="invalid_adapter")
|
||||||
|
|
||||||
|
snapshot = runtime.snapshot("sample.capability")
|
||||||
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
||||||
|
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_rejects_missing_canonical_symbol(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""adapter 未公开 manifest 指定符号时不得发布替代实现。"""
|
||||||
|
sys.modules["sample_implementation"] = types.ModuleType("sample_implementation")
|
||||||
|
adapter = _SyncAdapter()
|
||||||
|
runtime = CapabilityRuntime(
|
||||||
|
_registry(tmp_path),
|
||||||
|
adapters={"sample": adapter},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(adapter, "materialize", return_value=_SampleCapability), pytest.raises(
|
||||||
|
CapabilityOperationError,
|
||||||
|
match="canonical 符号.*不存在",
|
||||||
|
):
|
||||||
|
runtime.materialize("sample.capability", reason="invalid_adapter")
|
||||||
|
|
||||||
|
snapshot = runtime.snapshot("sample.capability")
|
||||||
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
||||||
|
assert snapshot.lifecycle is CapabilityLifecycleState.DISCOVERED
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_revalidates_missing_canonical_module_on_retry(tmp_path: Path) -> None:
|
||||||
|
"""同步激活的物化合同失败不得被记为已解析并在重试时绕过。"""
|
||||||
|
adapter = _SyncAdapter()
|
||||||
|
runtime = CapabilityRuntime(
|
||||||
|
_registry(tmp_path),
|
||||||
|
adapters={"sample": adapter},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
adapter,
|
||||||
|
"materialize",
|
||||||
|
return_value=_SampleCapability,
|
||||||
|
) as materialize:
|
||||||
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
||||||
|
runtime.activate("sample.capability", reason="invalid_adapter")
|
||||||
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
||||||
|
runtime.activate(
|
||||||
|
"sample.capability",
|
||||||
|
reason="invalid_adapter_retry",
|
||||||
|
retry=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = runtime.snapshot("sample.capability")
|
||||||
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
||||||
|
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
|
||||||
|
assert adapter.create_calls == 0
|
||||||
|
assert materialize.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activate_async_revalidates_missing_canonical_module_on_retry(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""异步激活的物化合同失败不得被记为已解析并在重试时绕过。"""
|
||||||
|
adapter = _AsyncAdapter()
|
||||||
|
runtime = CapabilityRuntime(
|
||||||
|
_registry(tmp_path),
|
||||||
|
adapters={"sample": adapter},
|
||||||
|
)
|
||||||
|
materialize = AsyncMock(return_value=_SampleCapability)
|
||||||
|
|
||||||
|
with patch.object(adapter, "materialize", new=materialize):
|
||||||
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
||||||
|
await runtime.activate_async("sample.capability", reason="invalid_adapter")
|
||||||
|
with pytest.raises(CapabilityOperationError, match="canonical 模块.*未加载"):
|
||||||
|
await runtime.activate_async(
|
||||||
|
"sample.capability",
|
||||||
|
reason="invalid_adapter_retry",
|
||||||
|
retry=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = runtime.snapshot("sample.capability")
|
||||||
|
assert snapshot.materialization is CapabilityMaterializationState.FAILED
|
||||||
|
assert snapshot.lifecycle is CapabilityLifecycleState.FAILED
|
||||||
|
assert adapter.create_calls == 0
|
||||||
|
assert materialize.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_state_read_calibrates_consumer_import_without_importing_new_module(tmp_path: Path) -> None:
|
def test_state_read_calibrates_consumer_import_without_importing_new_module(tmp_path: Path) -> None:
|
||||||
"""显式旧导入存在时应复用 sys.modules 中的 canonical symbol。"""
|
"""显式旧导入存在时应复用 sys.modules 中的 canonical symbol。"""
|
||||||
adapter = _SyncAdapter()
|
adapter = _SyncAdapter()
|
||||||
|
|||||||
Reference in New Issue
Block a user