diff --git a/app/runtime/extensions/module/contracts.py b/app/runtime/extensions/module/contracts.py index b85aa8fbb..ee273f972 100644 --- a/app/runtime/extensions/module/contracts.py +++ b/app/runtime/extensions/module/contracts.py @@ -86,7 +86,7 @@ _METHOD_CONTRACTS = { required_parameters=("meta", "media_source"), ), "obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.PIPELINE_RELAY, required_parameters=("mediainfo",)), - "media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"), + "media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="dict[str, list] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), "mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="Iterable[MediaServerItem] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "library_id", "start_index", "limit")), "mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")), "mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id")), @@ -114,6 +114,19 @@ _METHOD_CONTRACTS = { "storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="StorageProviderResult", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "action")), "snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path", "last_snapshot_time", "max_depth", "previous_snapshot")), "transfer": ModuleMethodContract(family="storage", input_contract="TransferRequest", result_contract="TransferInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "meta", "mediainfo", "target_directory", "target_storage", "target_path", "transfer_type", "scrape", "library_type_folder", "library_category_folder", "episodes_info", "source_oper", "target_oper", "preview")), + "load_category_config": ModuleMethodContract(family="category", input_contract="CategoryConfigReadRequest", result_contract="CategoryConfig | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), + "save_category_config": ModuleMethodContract(family="category", input_contract="CategoryConfigWriteRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("config",)), + "get_search_page_size": ModuleMethodContract(family="site", input_contract="SiteSearchPageSizeRequest", result_contract="int | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("site", "keyword")), + "refresh_userdata": ModuleMethodContract(family="site", input_contract="SiteUserDataRequest", result_contract="SiteUserData | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("site",)), + "site_subtitle_links": ModuleMethodContract(family="site", input_contract="SiteSubtitleLinksRequest", result_contract="list[str]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("context",)), + "metadata_img": ModuleMethodContract(family="metadata", input_contract="MetadataImageRequest", result_contract="dict[str, str] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("mediainfo", "season", "episode")), + "metadata_nfo": ModuleMethodContract(family="metadata", input_contract="MetadataNfoRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("meta", "mediainfo", "season", "episode")), + "obtain_specific_image": ModuleMethodContract(family="metadata", input_contract="SpecificImageRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("mediaid", "mtype", "image_type", "image_prefix", "season", "episode")), + "recommend_name": ModuleMethodContract(family="metadata", input_contract="RecommendNameRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("meta", "mediainfo", "episodes_info")), + "user_authenticate": ModuleMethodContract(family="authentication", input_contract="UserAuthenticationRequest", result_contract="AuthCredentials | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("credentials",)), + "tvdb_info": ModuleMethodContract(family="tvdb", input_contract="TvdbInfoRequest", result_contract="dict | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("tvdbid",)), + "tvdb_slug": ModuleMethodContract(family="tvdb", input_contract="TvdbInfoRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("tvdbid",)), + "search_tvdb": ModuleMethodContract(family="tvdb", input_contract="TvdbSearchRequest", result_contract="list[dict]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("title",)), "send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY), "finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("response",)), "register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None", required_parameters=("commands",)), diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 2f93cea39..d3b8c6ecd 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -4190,15 +4190,18 @@ "version": 1 }, "get_search_page_size": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "site", - "input_contract": "SiteKeywordArguments", + "input_contract": "SiteSearchPageSizeRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "SiteProviderResult", + "required_parameters": [ + "keyword", + "site" + ], + "result_contract": "int | None", "result_shape": "any", "supports_async": true, "supports_sync": true, @@ -4281,15 +4284,15 @@ "version": 1 }, "load_category_config": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "category", - "input_contract": "CategoryKeywordArguments", + "input_contract": "CategoryConfigReadRequest", "plugin_short_circuit": true, "public_to_plugins": true, "required_parameters": [], - "result_contract": "CategoryProviderResult", + "result_contract": "CategoryConfig | None", "result_shape": "any", "supports_async": true, "supports_sync": true, @@ -4377,7 +4380,7 @@ "version": 1 }, "media_category": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "media-recognition", @@ -4385,8 +4388,8 @@ "plugin_short_circuit": true, "public_to_plugins": true, "required_parameters": [], - "result_contract": "CategoryConfig | None", - "result_shape": "any", + "result_contract": "dict[str, list] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4682,32 +4685,41 @@ "version": 1 }, "metadata_img": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "metadata", - "input_contract": "MetadataKeywordArguments", + "input_contract": "MetadataImageRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MetadataProviderResult", - "result_shape": "any", + "required_parameters": [ + "episode", + "mediainfo", + "season" + ], + "result_contract": "dict[str, str] | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", "version": 1 }, "metadata_nfo": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "metadata", - "input_contract": "MetadataKeywordArguments", + "input_contract": "MetadataNfoRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MetadataProviderResult", - "result_shape": "any", + "required_parameters": [ + "episode", + "mediainfo", + "meta", + "season" + ], + "result_contract": "str | None", + "result_shape": "string", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -4972,16 +4984,23 @@ "version": 1 }, "obtain_specific_image": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "metadata", - "input_contract": "MetadataKeywordArguments", + "input_contract": "SpecificImageRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MetadataProviderResult", - "result_shape": "any", + "required_parameters": [ + "episode", + "image_prefix", + "image_type", + "mediaid", + "mtype", + "season" + ], + "result_contract": "str | None", + "result_shape": "string", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5011,16 +5030,20 @@ "version": 1 }, "recommend_name": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "metadata", - "input_contract": "MetadataKeywordArguments", + "input_contract": "RecommendNameRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "MetadataProviderResult", - "result_shape": "any", + "required_parameters": [ + "episodes_info", + "mediainfo", + "meta" + ], + "result_contract": "str | None", + "result_shape": "string", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5043,15 +5066,17 @@ "version": 1 }, "refresh_userdata": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "site", - "input_contract": "SiteKeywordArguments", + "input_contract": "SiteUserDataRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "SiteProviderResult", + "required_parameters": [ + "site" + ], + "result_contract": "SiteUserData | None", "result_shape": "any", "supports_async": true, "supports_sync": true, @@ -5116,16 +5141,18 @@ "version": 1 }, "save_category_config": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "category", - "input_contract": "CategoryKeywordArguments", + "input_contract": "CategoryConfigWriteRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "CategoryProviderResult", - "result_shape": "any", + "required_parameters": [ + "config" + ], + "result_contract": "bool | None", + "result_shape": "boolean", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5247,16 +5274,18 @@ "version": 1 }, "search_tvdb": { - "aggregation": "legacy", + "aggregation": "ordered_list_merge", "error_policy": "isolate_provider", "execution": "sync_or_async", - "family": "integration", - "input_contract": "IntegrationKeywordArguments", + "family": "tvdb", + "input_contract": "TvdbSearchRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "IntegrationProviderResult", - "result_shape": "any", + "required_parameters": [ + "title" + ], + "result_contract": "list[dict]", + "result_shape": "list", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5315,16 +5344,18 @@ "version": 1 }, "site_subtitle_links": { - "aggregation": "legacy", + "aggregation": "ordered_list_merge", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "site", - "input_contract": "SiteKeywordArguments", + "input_contract": "SiteSubtitleLinksRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "SiteProviderResult", - "result_shape": "any", + "required_parameters": [ + "context" + ], + "result_contract": "list[str]", + "result_shape": "list", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5828,32 +5859,36 @@ "version": 1 }, "tvdb_info": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "tvdb", - "input_contract": "TvdbKeywordArguments", + "input_contract": "TvdbInfoRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "TvdbProviderResult", - "result_shape": "any", + "required_parameters": [ + "tvdbid" + ], + "result_contract": "dict | None", + "result_shape": "mapping", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", "version": 1 }, "tvdb_slug": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "tvdb", - "input_contract": "TvdbKeywordArguments", + "input_contract": "TvdbInfoRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "TvdbProviderResult", - "result_shape": "any", + "required_parameters": [ + "tvdbid" + ], + "result_contract": "str | None", + "result_shape": "string", "supports_async": true, "supports_sync": true, "timeout_policy": "caller_budget", @@ -5922,15 +5957,17 @@ "version": 1 }, "user_authenticate": { - "aggregation": "legacy", + "aggregation": "first_non_empty", "error_policy": "isolate_provider", "execution": "sync_or_async", "family": "authentication", - "input_contract": "AuthenticationKeywordArguments", + "input_contract": "UserAuthenticationRequest", "plugin_short_circuit": true, "public_to_plugins": true, - "required_parameters": [], - "result_contract": "AuthenticationResult", + "required_parameters": [ + "credentials" + ], + "result_contract": "AuthCredentials | None", "result_shape": "any", "supports_async": true, "supports_sync": true, diff --git a/tests/test_module_method_contracts.py b/tests/test_module_method_contracts.py index edfed6b24..762ac453b 100644 --- a/tests/test_module_method_contracts.py +++ b/tests/test_module_method_contracts.py @@ -203,6 +203,34 @@ def test_storage_value_contracts_use_explicit_provider_semantics() -> None: assert media_files.required_parameters == ("mediainfo",) +def test_lookup_contracts_separate_value_routes_from_list_aggregation() -> None: + """分类、站点、元数据、认证与 TVDB 查询应声明真实的值或列表语义。""" + list_methods = {"site_subtitle_links", "search_tvdb"} + value_methods = { + "media_category", + "load_category_config", + "save_category_config", + "get_search_page_size", + "refresh_userdata", + "metadata_img", + "metadata_nfo", + "obtain_specific_image", + "recommend_name", + "user_authenticate", + "tvdb_info", + "tvdb_slug", + } + + for method in list_methods: + contract = get_module_method_contract(method) + assert contract.aggregation is ModuleResultAggregation.ORDERED_LIST_MERGE + assert contract.result_shape is ModuleResultShape.LIST + for method in value_methods: + contract = get_module_method_contract(method) + assert contract.aggregation is ModuleResultAggregation.FIRST_NON_EMPTY + assert contract.result_contract + + def test_heterogeneous_torrent_files_result_remains_legacy_compatible() -> None: """下载器文件集合尚未归一前不得声明虚假的列表聚合语义。""" contract = get_module_method_contract("torrent_files")