refactor: enforce module fanout contracts

This commit is contained in:
jxxghp
2026-08-24 02:02:01 +08:00
parent 19653b1437
commit 83318b12c0
5 changed files with 130 additions and 31 deletions
+33 -26
View File
@@ -3762,15 +3762,15 @@
"version": 1
},
"clear_cache": {
"aggregation": "legacy",
"aggregation": "fan_out",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "category",
"input_contract": "CategoryKeywordArguments",
"plugin_short_circuit": true,
"input_contract": "CacheClearRequest",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "CategoryProviderResult",
"result_contract": "None",
"result_shape": "any",
"supports_async": true,
"supports_sync": true,
@@ -4011,15 +4011,19 @@
"version": 1
},
"download_added": {
"aggregation": "legacy",
"aggregation": "fan_out",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "downloader",
"input_contract": "DownloaderKeywordArguments",
"plugin_short_circuit": true,
"input_contract": "DownloadAddedHook",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "DownloaderProviderResult",
"required_parameters": [
"context",
"download_dir",
"torrent_content"
],
"result_contract": "None",
"result_shape": "any",
"supports_async": true,
"supports_sync": true,
@@ -5160,15 +5164,15 @@
"version": 1
},
"music_cache_clear": {
"aggregation": "legacy",
"aggregation": "fan_out",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "music",
"input_contract": "MusicKeywordArguments",
"plugin_short_circuit": true,
"input_contract": "MusicCacheClearRequest",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "MusicProviderResult",
"result_contract": "None",
"result_shape": "any",
"supports_async": true,
"supports_sync": true,
@@ -5420,12 +5424,12 @@
"version": 1
},
"register_commands": {
"aggregation": "legacy",
"aggregation": "fan_out",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "messaging",
"input_contract": "CommandRegistrationRequest",
"plugin_short_circuit": true,
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [
"commands"
@@ -5495,12 +5499,12 @@
"version": 1
},
"scheduler_job": {
"aggregation": "legacy",
"aggregation": "fan_out",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "scheduling",
"input_contract": "SchedulerJobRequest",
"plugin_short_circuit": true,
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "None",
@@ -5798,15 +5802,15 @@
"version": 1
},
"tmdb_cache_clear": {
"aggregation": "legacy",
"aggregation": "fan_out",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "tmdb",
"input_contract": "TmdbKeywordArguments",
"plugin_short_circuit": true,
"input_contract": "TmdbCacheClearRequest",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "TmdbProviderResult",
"result_contract": "None",
"result_shape": "any",
"supports_async": true,
"supports_sync": true,
@@ -6184,15 +6188,18 @@
"version": 1
},
"transfer_completed": {
"aggregation": "legacy",
"aggregation": "fan_out",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "downloader",
"input_contract": "DownloaderKeywordArguments",
"plugin_short_circuit": true,
"input_contract": "TransferCompletedHook",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "DownloaderProviderResult",
"required_parameters": [
"downloader",
"hashs"
],
"result_contract": "None",
"result_shape": "any",
"supports_async": true,
"supports_sync": true,
@@ -94,6 +94,56 @@ def test_plugin_scalar_short_circuits_system_modules() -> None:
system_call.assert_not_called()
def test_fan_out_contract_runs_every_provider_and_ignores_results() -> None:
"""副作用广播应执行全部插件和宿主 provider,并稳定返回 None。"""
calls = []
def record(name: str, result):
"""生成记录调用顺序并返回测试哨兵的 provider。"""
return lambda: calls.append(name) or result
system_20 = _Module("系统二", 20, record("system-20", "ignored-system"))
system_10 = _Module("系统一", 10, record("system-10", None))
setattr(system_20, "clear_cache", system_20.execute)
setattr(system_10, "clear_cache", system_10.execute)
dispatcher, _, _, _ = _dispatcher(
plugins={
("P1", "插件一"): {"clear_cache": record("plugin-1", "ignored-plugin")},
("P2", "插件二"): {"clear_cache": record("plugin-2", None)},
},
modules=[system_20, system_10],
)
assert dispatcher.dispatch("clear_cache") is None
assert calls == ["plugin-1", "plugin-2", "system-10", "system-20"]
@pytest.mark.asyncio
async def test_async_fan_out_contract_matches_sync_execution() -> None:
"""异步广播也应忽略返回值并执行全部同步或异步 provider。"""
calls = []
async def plugin_call():
"""记录异步插件调用并返回应被忽略的哨兵。"""
calls.append("plugin")
return "ignored-plugin"
def system_call():
"""记录同步宿主调用并返回应被忽略的哨兵。"""
calls.append("system")
return "ignored-system"
module = _Module("系统", 10, system_call)
setattr(module, "clear_cache", module.execute)
dispatcher, _, _, _ = _dispatcher(
plugins={("P1", "插件一"): {"clear_cache": plugin_call}},
modules=[module],
)
assert await dispatcher.async_dispatch("clear_cache") is None
assert calls == ["plugin", "system"]
def test_list_results_merge_in_plugin_then_priority_order() -> None:
"""列表结果应先按插件顺序合并,再按宿主优先级继续合并。"""
calls = []
+20 -1
View File
@@ -34,7 +34,7 @@ def test_all_scanned_host_module_methods_have_explicit_v2_contracts() -> None:
assert isinstance(contract.aggregation, ModuleResultAggregation)
assert contract.input_contract != "legacy_args"
assert contract.result_contract != "Any"
assert contract.plugin_short_circuit is True
assert isinstance(contract.plugin_short_circuit, bool)
def test_high_frequency_capability_families_are_explicit() -> None:
@@ -298,6 +298,25 @@ def test_torrent_filter_contract_preserves_original_argument_list_merge() -> Non
)
def test_side_effect_hooks_use_non_short_circuiting_fan_out_contracts() -> None:
"""副作用钩子必须执行全部 provider,不能被任意返回值提前截断。"""
methods = {
"clear_cache",
"download_added",
"music_cache_clear",
"register_commands",
"scheduler_job",
"tmdb_cache_clear",
"transfer_completed",
}
for method in methods:
contract = get_module_method_contract(method)
assert contract.aggregation is ModuleResultAggregation.FAN_OUT
assert contract.result_contract == "None"
assert contract.plugin_short_circuit is False
def test_heterogeneous_torrent_files_result_remains_legacy_compatible() -> None:
"""下载器文件集合尚未归一前不得声明虚假的列表聚合语义。"""
contract = get_module_method_contract("torrent_files")