"""字符串模块方法协议的可检查契约清单。""" from __future__ import annotations import inspect from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum from typing import Any, Protocol class ModuleResultAggregation(StrEnum): """描述多模块结果沿调用链的兼容聚合方式。""" LEGACY = "legacy" FIRST_NON_EMPTY = "first_non_empty" ORDERED_LIST_MERGE = "ordered_list_merge" ORDERED_MAPPING_MERGE = "ordered_mapping_merge" PIPELINE_RELAY = "pipeline_relay" class ModuleResultShape(StrEnum): """描述模块 provider 返回值的基础 Python 形状。""" ANY = "any" LIST = "list" STRING = "string" MAPPING = "mapping" BOOLEAN = "boolean" BYTES = "bytes" class ModuleExecutionMode(StrEnum): """描述 provider 可以采用的执行形态。""" SYNC_OR_ASYNC = "sync_or_async" class ModuleErrorPolicy(StrEnum): """描述单个 provider 失败后的兼容处理策略。""" ISOLATE_PROVIDER = "isolate_provider" class ModuleCapability(Protocol): """宿主和新插件可用于声明动态能力的最小 Protocol。""" def __call__(self, *args: Any, **kwargs: Any) -> Any: """执行模块能力并返回契约声明的结果。""" @dataclass(frozen=True, slots=True) class ModuleMethodContract: """记录模块方法的输入、结果、执行与兼容错误协议。""" family: str aggregation: ModuleResultAggregation = ModuleResultAggregation.LEGACY version: int = 1 input_contract: str = "legacy_args" result_contract: str = "Any" result_shape: ModuleResultShape = ModuleResultShape.ANY required_parameters: tuple[str, ...] = () execution: ModuleExecutionMode = ModuleExecutionMode.SYNC_OR_ASYNC timeout_policy: str = "caller_budget" error_policy: ModuleErrorPolicy = ModuleErrorPolicy.ISOLATE_PROVIDER public_to_plugins: bool = True supports_sync: bool = True supports_async: bool = True plugin_short_circuit: bool = True _DEFAULT_CONTRACT = ModuleMethodContract(family="legacy") # 首批登记高频能力族。方法名仍保持开放字符串,以兼容第三方插件自定义模块能力; # 未命中项继续使用冻结的 legacy 规则,并由架构快照记录新增调用位置。 _METHOD_CONTRACTS = { "recognize_media": ModuleMethodContract( family="media-recognition", input_contract="MediaRecognitionRequest", result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("meta", "mtype", "media_source", "media_id", "episode_group", "cache"), ), "search_medias": ModuleMethodContract( family="media-recognition", input_contract="MediaSearchRequest", result_contract="list[MediaInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, 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"), "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")), "mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "item_id")), "media_exists": ModuleMethodContract(family="media-server", input_contract="MediaExistsRequest", result_contract="ExistMediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("mediainfo", "itemid", "server")), "media_statistic": ModuleMethodContract(family="media-server", input_contract="MediaStatisticRequest", result_contract="list[Statistic]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server",)), "mediaserver_image_cookies": ModuleMethodContract(family="media-server", input_contract="MediaServerImageCookiesRequest", result_contract="str | dict | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "image_url")), "mediaserver_items_count": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsCountRequest", result_contract="int | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "library_id")), "mediaserver_latest": ModuleMethodContract(family="media-server", input_contract="MediaServerRecentRequest", result_contract="list[MediaServerPlayItem]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "count", "username")), "mediaserver_latest_images": ModuleMethodContract(family="media-server", input_contract="MediaServerRecentImagesRequest", result_contract="list[str]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "count", "remote", "username")), "mediaserver_librarys": ModuleMethodContract(family="media-server", input_contract="MediaServerLibrariesRequest", result_contract="list[MediaServerLibrary]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "username", "hidden")), "mediaserver_playing": ModuleMethodContract(family="media-server", input_contract="MediaServerRecentRequest", result_contract="list[MediaServerPlayItem]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("server", "count", "username")), "mediaserver_season_episode_ids": ModuleMethodContract(family="media-server", input_contract="MediaServerSeasonEpisodesRequest", result_contract="dict[int, str] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("server", "item_id", "season")), "any_files": ModuleMethodContract(family="storage", input_contract="StorageAnyFilesRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "extensions")), "create_folder": ModuleMethodContract(family="storage", input_contract="StorageCreateFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "name")), "delete_file": ModuleMethodContract(family="storage", input_contract="StorageDeleteRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem",)), "download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path")), "upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "path", "new_name")), "list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("fileitem", "recursion")), "media_files": ModuleMethodContract(family="storage", input_contract="MediaFilesRequest", result_contract="list[FileItem]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("mediainfo",)), "get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")), "get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("storage", "path")), "get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem",)), "rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("fileitem", "name")), "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")), "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",)), "scheduler_job": ModuleMethodContract(family="scheduling", input_contract="SchedulerJobRequest", result_contract="None"), "webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("body", "form", "args")), "download_discord_file_bytes": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_ref", "source")), "download_feishu_file_bytes": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_ref", "source")), "download_feishu_image_to_data_url": ModuleMethodContract(family="messaging", input_contract="MessageImageDownloadRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("image_ref", "source")), "download_qq_file_bytes": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_ref", "source")), "download_slack_file_bytes": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_ref", "source")), "download_slack_file_to_data_url": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_url", "source")), "download_synologychat_file_bytes": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_ref", "source")), "download_telegram_file_bytes": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_id", "source")), "download_telegram_file_to_base64": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_id", "source")), "download_vocechat_file_bytes": ModuleMethodContract(family="messaging", input_contract="MessageFileDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("file_ref", "source")), "download_vocechat_image_to_data_url": ModuleMethodContract(family="messaging", input_contract="MessageImageDownloadRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("image_ref", "source")), "download_wechat_image_to_data_url": ModuleMethodContract(family="messaging", input_contract="MessageImageDownloadRequest", result_contract="str | None", result_shape=ModuleResultShape.STRING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("image_ref", "source")), "download_wechat_media_bytes": ModuleMethodContract(family="messaging", input_contract="MessageMediaDownloadRequest", result_contract="bytes | None", result_shape=ModuleResultShape.BYTES, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("media_ref", "source")), "downloader_info": ModuleMethodContract(family="downloader", input_contract="DownloaderInfoRequest", result_contract="list[DownloaderInfo]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("downloader",)), "list_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentListRequest", result_contract="list[DownloaderTorrent]", result_shape=ModuleResultShape.LIST, aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE, required_parameters=("status", "hashs", "downloader", "include_all_tags")), "torrent_files": ModuleMethodContract(family="downloader", input_contract="TorrentFilesRequest", result_contract="DownloaderFileCollection | None", required_parameters=("tid", "downloader")), "get_torrent_trackers": ModuleMethodContract(family="downloader", input_contract="TorrentTrackersRequest", result_contract="dict[str, list[str]] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.ORDERED_MAPPING_MERGE, required_parameters=("hash_string", "downloader")), "download": ModuleMethodContract(family="downloader", input_contract="DownloadTaskRequest", result_contract="DownloadTaskResult | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("content", "download_dir", "cookie", "episodes", "category", "label", "downloader")), "remove_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentRemoveRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hashs", "delete_file", "downloader")), "set_torrents_tag": ModuleMethodContract(family="downloader", input_contract="TorrentTagRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hashs", "tags", "downloader")), "start_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentControlRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hashs", "downloader")), "stop_torrents": ModuleMethodContract(family="downloader", input_contract="TorrentControlRequest", result_contract="bool | None", result_shape=ModuleResultShape.BOOLEAN, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hashs", "downloader")), "update_torrent": ModuleMethodContract(family="downloader", input_contract="TorrentUpdateRequest", result_contract="dict[str, bool] | None", result_shape=ModuleResultShape.MAPPING, aggregation=ModuleResultAggregation.FIRST_NON_EMPTY, required_parameters=("hash_string", "downloader", "download_limit", "upload_limit", "tracker_list", "save_path", "category", "ratio_limit", "seeding_time_limit")), } # 同一能力的同步/异步入口共享不可变契约对象,避免参数和聚合语义各自漂移。 _METHOD_CONTRACTS.update({ "async_recognize_media": _METHOD_CONTRACTS["recognize_media"], "async_search_medias": _METHOD_CONTRACTS["search_medias"], "async_obtain_images": _METHOD_CONTRACTS["obtain_images"], }) _PREFIX_CONTRACTS = ( ("async_tmdb_", ModuleMethodContract(family="tmdb")), ("tmdb_", ModuleMethodContract(family="tmdb")), ("async_douban_", ModuleMethodContract(family="douban")), ("douban_", ModuleMethodContract(family="douban")), ("async_bangumi_", ModuleMethodContract(family="bangumi")), ("bangumi_", ModuleMethodContract(family="bangumi")), ("async_anilist_", ModuleMethodContract(family="anilist")), ("anilist_", ModuleMethodContract(family="anilist")), ("tvdb_", ModuleMethodContract(family="tvdb")), ("music_", ModuleMethodContract(family="music")), ("torrent_", ModuleMethodContract(family="downloader")), ) # 宿主静态扫描到的全部字符串能力名。第三方插件仍可声明未在这里出现的自定义方法, # 自定义方法继续走开放的 legacy contract;宿主新增调用则必须先进入本清单。 _OBSERVED_HOST_METHODS = ( 'anilist_credits', 'anilist_discover', 'anilist_info', 'anilist_person_credits', 'anilist_person_detail', 'anilist_popular_this_season', 'anilist_recommendations', 'anilist_trending', 'any_files', 'async_anilist_credits', 'async_anilist_discover', 'async_anilist_info', 'async_anilist_person_credits', 'async_anilist_person_detail', 'async_anilist_popular_this_season', 'async_anilist_recommendations', 'async_anilist_trending', 'async_bangumi_calendar', 'async_bangumi_credits', 'async_bangumi_discover', 'async_bangumi_info', 'async_bangumi_person_credits', 'async_bangumi_person_detail', 'async_bangumi_recommend', 'async_douban_discover', 'async_douban_info', 'async_douban_movie_credits', 'async_douban_movie_recommend', 'async_douban_person_credits', 'async_douban_person_detail', 'async_douban_tv_credits', 'async_douban_tv_recommend', 'async_identify_music_by_fingerprint', 'async_match_doubaninfo', 'async_match_music_album', 'async_match_tmdbinfo', 'async_movie_hot', 'async_movie_showing', 'async_movie_top250', 'async_obtain_images', 'async_recognize_media', 'async_refresh_torrents', 'async_search_collections', 'async_search_medias', 'async_search_persons', 'async_search_subtitles', 'async_search_torrents', 'async_tmdb_collection', 'async_tmdb_discover', 'async_tmdb_episodes', 'async_tmdb_group_seasons', 'async_tmdb_info', 'async_tmdb_movie_credits', 'async_tmdb_movie_recommend', 'async_tmdb_movie_similar', 'async_tmdb_person_credits', 'async_tmdb_person_detail', 'async_tmdb_seasons', 'async_tmdb_trending', 'async_tmdb_tv_credits', 'async_tmdb_tv_recommend', 'async_tmdb_tv_similar', 'async_tv_animation', 'async_tv_hot', 'async_tv_weekly_chinese', 'async_tv_weekly_global', 'async_update_recognize_cache', 'bangumi_calendar', 'bangumi_credits', 'bangumi_discover', 'bangumi_info', 'bangumi_person_credits', 'bangumi_person_detail', 'bangumi_recommend', 'channel_manage', 'clear_cache', 'create_folder', 'delete_file', 'delete_message', 'douban_discover', 'douban_info', 'douban_movie_credits', 'douban_movie_recommend', 'douban_person_credits', 'douban_person_detail', 'douban_tv_credits', 'douban_tv_recommend', 'download', 'download_added', 'download_discord_file_bytes', 'download_feishu_file_bytes', 'download_feishu_image_to_data_url', 'download_file', 'download_qq_file_bytes', 'download_slack_file_bytes', 'download_slack_file_to_data_url', 'download_synologychat_file_bytes', 'download_telegram_file_bytes', 'download_telegram_file_to_base64', 'download_vocechat_file_bytes', 'download_vocechat_image_to_data_url', 'download_wechat_image_to_data_url', 'download_wechat_media_bytes', 'downloader_info', 'edit_message', 'filter_torrents', 'finalize_message', 'get_file_item', 'get_folder', 'get_parent_item', 'get_search_page_size', 'get_torrent_trackers', 'identify_music_by_fingerprint', 'list_files', 'list_torrents', 'load_category_config', 'mark_message_processing_finished', 'mark_message_processing_started', 'match_doubaninfo', 'match_music_album', 'match_tmdbinfo', 'media_category', 'media_exists', 'media_files', 'media_statistic', 'mediaserver_image_cookies', 'mediaserver_iteminfo', 'mediaserver_items', 'mediaserver_items_count', 'mediaserver_latest', 'mediaserver_latest_images', 'mediaserver_librarys', 'mediaserver_play_url', 'mediaserver_playing', 'mediaserver_season_episode_ids', 'mediaserver_tv_episodes', 'message_parser', 'metadata_img', 'metadata_nfo', 'movie_hot', 'movie_showing', 'movie_top250', 'music_album', 'music_album_related', 'music_artist', 'music_artist_albums', 'music_artist_related', 'music_cache_clear', 'music_cache_delete', 'music_cache_items', 'music_chart', 'music_discover', 'music_fresh_releases', 'music_lyrics', 'obtain_images', 'obtain_specific_image', 'recognize_media', 'recommend_name', 'refresh_torrents', 'refresh_userdata', 'register_commands', 'remove_torrents', 'rename_file', 'save_category_config', 'scheduler_job', 'search_collections', 'search_medias', 'search_music', 'search_persons', 'search_subtitles', 'search_torrents', 'search_tvdb', 'send_direct_message', 'set_torrents_tag', 'site_subtitle_links', 'snapshot_storage', 'start_torrents', 'stop_torrents', 'storage_manage', 'tmdb_cache_clear', 'tmdb_cache_delete', 'tmdb_cache_items', 'tmdb_collection', 'tmdb_discover', 'tmdb_episodes', 'tmdb_group_seasons', 'tmdb_info', 'tmdb_movie_credits', 'tmdb_movie_recommend', 'tmdb_movie_similar', 'tmdb_person_credits', 'tmdb_person_detail', 'tmdb_seasons', 'tmdb_trending', 'tmdb_tv_credits', 'tmdb_tv_recommend', 'tmdb_tv_similar', 'torrent_files', 'transfer', 'transfer_completed', 'tv_animation', 'tv_hot', 'tv_weekly_chinese', 'tv_weekly_global', 'tvdb_info', 'tvdb_slug', 'update_recognize_cache', 'update_torrent', 'upload_file', 'user_authenticate', 'webhook_parser', ) _FAMILY_IO_CONTRACTS = { "anilist": ("AniListKeywordArguments", "AniListProviderResult"), "authentication": ("AuthenticationKeywordArguments", "AuthenticationResult"), "bangumi": ("BangumiKeywordArguments", "BangumiProviderResult"), "category": ("CategoryKeywordArguments", "CategoryProviderResult"), "douban": ("DoubanKeywordArguments", "DoubanProviderResult"), "downloader": ("DownloaderKeywordArguments", "DownloaderProviderResult"), "integration": ("IntegrationKeywordArguments", "IntegrationProviderResult"), "media-discovery": ("MediaDiscoveryKeywordArguments", "MediaDiscoveryProviderResult"), "media-recognition": ("MediaRecognitionKeywordArguments", "MediaRecognitionProviderResult"), "media-server": ("MediaServerKeywordArguments", "MediaServerProviderResult"), "messaging": ("MessagingKeywordArguments", "MessagingProviderResult"), "metadata": ("MetadataKeywordArguments", "MetadataProviderResult"), "music": ("MusicKeywordArguments", "MusicProviderResult"), "site": ("SiteKeywordArguments", "SiteProviderResult"), "storage": ("StorageKeywordArguments", "StorageProviderResult"), "tmdb": ("TmdbKeywordArguments", "TmdbProviderResult"), "tvdb": ("TvdbKeywordArguments", "TvdbProviderResult"), } def _infer_observed_family(method: str) -> str: """按稳定能力前缀把已观察宿主方法归入可审计的输入/结果族。""" for prefix, contract in _PREFIX_CONTRACTS: if method.startswith(prefix): return contract.family if method.startswith(("mediaserver_", "media_exists", "media_statistic")): return "media-server" if method.startswith(( "download", "torrent_", "list_torrents", "refresh_torrents", "remove_torrents", "start_torrents", "stop_torrents", "set_torrents_tag", "update_torrent", "get_torrent_trackers", "downloader_info", "filter_torrents", "transfer_completed", )): return "downloader" if method.startswith(( "channel_", "delete_message", "edit_message", "finalize_message", "mark_message_", "message_parser", "register_commands", "send_direct_message", "send_message", )): return "messaging" if method.startswith(( "any_files", "create_folder", "delete_file", "get_file_item", "get_folder", "get_parent_item", "list_files", "media_files", "rename_file", "snapshot_storage", "storage_manage", "transfer", "upload_file", )): return "storage" if method.startswith(( "metadata_", "obtain_specific_image", "recommend_name", )): return "metadata" if method.startswith(( "async_identify_music", "async_match_music", "identify_music", "match_music", "search_music", )): return "music" if method.startswith(( "async_match_", "async_obtain_images", "async_recognize_media", "async_update_recognize_cache", "match_", "obtain_images", "recognize_media", "update_recognize_cache", )): return "media-recognition" if method.startswith(( "async_movie_", "async_search_", "async_tv_", "movie_", "search_collections", "search_medias", "search_persons", "search_subtitles", "search_torrents", "tv_", )): return "media-discovery" if method in {"clear_cache", "load_category_config", "save_category_config"}: return "category" if method in {"get_search_page_size", "refresh_userdata", "site_subtitle_links"}: return "site" if method == "user_authenticate": return "authentication" return "integration" def _register_observed_host_contracts() -> None: """为全部宿主字符串调用登记完整 V2 字段,保留未知插件方法的 legacy fallback。""" for method in _OBSERVED_HOST_METHODS: if method in _METHOD_CONTRACTS: continue family = _infer_observed_family(method) input_contract, result_contract = _FAMILY_IO_CONTRACTS[family] _METHOD_CONTRACTS[method] = ModuleMethodContract( family=family, input_contract=input_contract, result_contract=result_contract, ) _register_observed_host_contracts() def get_module_method_contract(method: str) -> ModuleMethodContract: """返回方法的显式能力族契约,未知方法保持既有 legacy 协议。""" if contract := _METHOD_CONTRACTS.get(method): return contract for prefix, contract in _PREFIX_CONTRACTS: if method.startswith(prefix): return contract return _DEFAULT_CONTRACT def is_explicit_module_method(method: str) -> bool: """判断方法是否已进入首批显式能力族清单。""" return get_module_method_contract(method) is not _DEFAULT_CONTRACT def diagnose_module_callable(method: str, callback: Callable[..., Any]) -> tuple[str, ...]: """诊断显式能力的基础签名;兼容阶段只返回问题,不拒绝 provider。""" contract = get_module_method_contract(method) if contract is _DEFAULT_CONTRACT: return () try: parameters = inspect.signature(callback).parameters except (TypeError, ValueError): return ("signature-unavailable",) missing = tuple( name for name in contract.required_parameters if name not in parameters and not any( parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() ) ) return tuple(f"missing-parameter:{name}" for name in missing) def diagnose_module_result(method: str, result: Any) -> tuple[str, ...]: """诊断显式模块结果的基础形状,兼容阶段只告警而不改写返回值。""" shape = get_module_method_contract(method).result_shape if shape is ModuleResultShape.ANY or result is None: return () matches = { ModuleResultShape.LIST: isinstance(result, list), ModuleResultShape.STRING: isinstance(result, str), ModuleResultShape.MAPPING: isinstance(result, dict), ModuleResultShape.BOOLEAN: isinstance(result, bool), ModuleResultShape.BYTES: isinstance(result, bytes), } if matches.get(shape, True): return () return (f"unexpected-result:{shape.value}:{type(result).__name__}",) def list_explicit_module_contracts() -> dict[str, ModuleMethodContract]: """返回显式方法清单的副本,供架构基线和 SDK 文档使用。""" return dict(_METHOD_CONTRACTS)