diff --git a/app/modules/__init__.py b/app/modules/__init__.py index 36245c725..f86c2d1c8 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -1,7 +1,7 @@ import threading from abc import ABCMeta, abstractmethod from pathlib import Path -from typing import Any, Callable, Dict, Generic, Optional, Tuple, Type, TypeVar, Union +from typing import Any, Callable, Dict, Generic, Mapping, Optional, Protocol, Tuple, Type, TypeVar, Union from app.runtime.extensions.service import ServiceConfigHelper from app.runtime.log import logger @@ -45,7 +45,8 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): ) self.init_module() - def get_reload_name(self): + def get_reload_name(self) -> Optional[str]: + """返回模块声明的日志名称,未实现元数据方法时保留 None。""" return self.get_name() @abstractmethod @@ -64,14 +65,14 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): pass @staticmethod - def get_name() -> str: + def get_name() -> Optional[str]: """ 获取模块名称 """ pass @staticmethod - def get_type() -> ModuleType: + def get_type() -> Optional[ModuleType]: """ 获取模块类型 """ @@ -85,6 +86,7 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): StorageSchema, OtherModulesType, MediaRecognizeType, + None, ]: """ 获取模块子类型(下载器、媒体服务器、消息通道、存储类型、其他杂项模块类型) @@ -92,7 +94,7 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): pass @staticmethod - def get_priority() -> int: + def get_priority() -> Optional[int]: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ @@ -114,9 +116,23 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): pass +class _ServiceConfig(Protocol): + """服务配置提供的名称与构造参数;保留现有模型允许的空配置。""" + + @property + def name(self) -> Optional[str]: + """读取配置原始名称,包括尚未命名的配置。""" + ... + + @property + def config(self) -> Optional[Mapping[str, object]]: + """读取服务构造参数,None 仍由实例化入口报错。""" + ... + + # 定义泛型,用于表示具体的服务类型和配置类型 TService = TypeVar("TService", bound=object) -TConf = TypeVar("TConf") +TConf = TypeVar("TConf", bound=_ServiceConfig) class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta): @@ -124,16 +140,16 @@ class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta): 抽象服务基类,负责服务的初始化、获取实例和配置管理 """ - def __init__(self): + def __init__(self) -> None: """ 初始化 ServiceBase 类的实例 """ - self._configs: Optional[Dict[str, TConf]] = None - self._instances: Optional[Dict[str, TService]] = None + self._configs: Optional[Dict[Optional[str], TConf]] = None + self._instances: Optional[Dict[Optional[str], TService]] = None self._service_name: Optional[str] = None def init_service(self, service_name: str, - service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None): + service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None) -> None: """ 初始化服务,获取配置并实例化对应服务 @@ -163,9 +179,14 @@ class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta): def _service_kwargs(self, conf: TConf) -> Dict[str, Any]: """构建类服务实例参数,允许具体模块传递稳定配置身份。""" - return {"name": conf.name, **conf.config} + name = conf.name + config = conf.config + if config is None: + # 保留原字典解包对空配置的 TypeError,不能静默实例化默认连接。 + raise TypeError("'NoneType' object is not a mapping") + return {"name": name, **config} - def get_instances(self) -> Dict[str, TService]: + def get_instances(self) -> Dict[Optional[str], TService]: """ 获取服务实例列表 @@ -188,7 +209,7 @@ class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta): return self._instances.get(name) if name else None @abstractmethod - def get_configs(self) -> Dict[str, TConf]: + def get_configs(self) -> Dict[Optional[str], TConf]: """ 获取已启用的服务配置字典 @@ -220,13 +241,16 @@ class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta): :return: 默认配置的名称,无配置时为 None """ + configs = self._configs + if configs is None: + return None marked = next( - (conf for conf in self._configs.values() if getattr(conf, "default", False)), + (conf for conf in configs.values() if getattr(conf, "default", False)), None, ) if marked: return marked.name - first_conf = next(iter(self._configs.values()), None) + first_conf = next(iter(configs.values()), None) return first_conf.name if first_conf else None @@ -240,14 +264,14 @@ class _MessageBase(ServiceBase[TService, NotificationConf]): # 管理员未绑定个人 ID 时可作为管理员默认目标的渠道配置键。 _notification_admin_default_config_keys: tuple[str, ...] = () - def __init__(self): + def __init__(self) -> None: """ 初始化消息基类,并设置消息通道 """ super().__init__() self._channel: Optional[NotificationChannel] = None - def get_configs(self) -> Dict[str, NotificationConf]: + def get_configs(self) -> Dict[Optional[str], NotificationConf]: """ 获取已启用的消息通知渠道的配置字典 @@ -258,7 +282,7 @@ class _MessageBase(ServiceBase[TService, NotificationConf]): return {} return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled} - def check_message(self, message: Message, source: str = None) -> bool: + def check_message(self, message: Message, source: Optional[str] = None) -> bool: """ 检查消息渠道及消息类型,判断是否处理消息 @@ -344,7 +368,7 @@ class _DownloaderBase(ServiceBase[TService, DownloaderConf]): """ CONFIG_WATCH = {SystemConfigKey.Downloaders.value} - def __init__(self): + def __init__(self) -> None: """ 初始化下载器基类 """ @@ -352,7 +376,7 @@ class _DownloaderBase(ServiceBase[TService, DownloaderConf]): self._default_config_name: Optional[str] = None def init_service(self, service_name: str, - service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None): + service_type: Optional[Union[Type[TService], Callable[..., TService]]] = None) -> None: """ 初始化服务,获取配置并实例化对应服务 @@ -384,7 +408,7 @@ class _DownloaderBase(ServiceBase[TService, DownloaderConf]): self._default_config_name = first_conf.name if first_conf else None return self._default_config_name - def get_configs(self) -> Dict[str, DownloaderConf]: + def get_configs(self) -> Dict[Optional[str], DownloaderConf]: """ 获取已启用的下载器的配置字典 @@ -395,7 +419,7 @@ class _DownloaderBase(ServiceBase[TService, DownloaderConf]): return {} return {conf.name: conf for conf in configs if conf.type == self._service_name and conf.enabled} - def reset_default_config_name(self): + def reset_default_config_name(self) -> None: """ 重置默认配置名称 """ @@ -476,7 +500,7 @@ class _MediaServerBase(ServiceBase[TService, MediaServerConf]): """ CONFIG_WATCH = {SystemConfigKey.MediaServers.value} - def get_configs(self) -> Dict[str, MediaServerConf]: + def get_configs(self) -> Dict[Optional[str], MediaServerConf]: """ 获取已启用的媒体服务器的配置字典 diff --git a/tests/test_service_base_contract.py b/tests/test_service_base_contract.py new file mode 100644 index 000000000..c227469e1 --- /dev/null +++ b/tests/test_service_base_contract.py @@ -0,0 +1,111 @@ +"""公共服务基类的可空配置与实例化边界。""" + +import pytest + +from app.modules import ServiceBase, _ModuleBase +from app.schemas.system import NotificationConf + + +class _Service(ServiceBase[object, NotificationConf]): + """通过内存配置测试公共服务逻辑,不建立外部连接。""" + + def __init__(self, configs): + """保留初始化前的默认状态并记录可供加载的配置。""" + super().__init__() + self.available_configs = configs + + def get_configs(self): + """返回调用方指定的配置。""" + return self.available_configs + + +def test_default_name_before_initialization_is_empty(): + """初始化前不存在默认配置,应与 get_config/get_instance 一样返回 None。""" + service = _Service({}) + assert service.get_default_config_name() is None + assert service.get_config() is None + assert service.get_instance() is None + + +@pytest.mark.parametrize("name", [None, "named"]) +def test_class_factory_preserves_name_override_and_config_identity(name): + """配置名称可空,构造参数中的 name 仍按原字典展开顺序覆盖配置名。""" + token = object() + config = NotificationConf(name=name, config={"name": "override", "token": token}) + service = _Service({name: config}) + + class Client: + """记录收到的原始配置参数。""" + + def __init__(self, **kwargs): + """保留构造参数供身份断言使用。""" + self.kwargs = kwargs + + service.init_service("client", Client) + client = service.get_instances()[name] + assert client.kwargs == {"name": "override", "token": token} + assert client.kwargs["token"] is token + assert service.get_configs()[name] is config + + +@pytest.mark.parametrize("name", [None, "named"]) +def test_callable_factory_receives_original_configuration(name): + """函数工厂仍接收配置对象本身,包括名称与 config 为空的情况。""" + config = NotificationConf(name=name, config=None) + service = _Service({name: config}) + calls = [] + instance = object() + + def factory(conf): + """记录原始配置,避免被转换为构造参数。""" + calls.append(conf) + return instance + + service.init_service("client", factory) + assert calls == [config] + assert calls[0] is config + assert service.get_instances()[name] is instance + + +def test_class_factory_rejects_none_config_without_constructing_client(): + """空配置继续触发原字典解包的 TypeError,不能误创建默认连接。""" + service = _Service({"named": NotificationConf(name="named", config=None)}) + calls = [] + + class Client: + """在构造被意外调用时留下可检查的证据。""" + + def __init__(self, **kwargs): + """记录客户端的实际创建。""" + calls.append(kwargs) + + with pytest.raises(TypeError, match="'NoneType' object is not a mapping"): + service.init_service("client", Client) + assert calls == [] + assert service.get_instances() == {} + + +def test_missing_module_metadata_retains_implicit_none(): + """已有模块可以只实现原抽象方法;未提供的元数据仍为 None。""" + + class Module(_ModuleBase): + """仅实现生命周期抽象方法的兼容模块。""" + + def init_module(self): + """无需初始化外部资源。""" + + def init_setting(self): + """没有模块设置开关。""" + + def stop(self): + """没有需要释放的外部资源。""" + + def test(self): + """没有需要连接的外部服务。""" + + module = Module() + assert module.get_name() is None + assert module.get_reload_name() is None + assert module.get_type() is None + assert module.get_subtype() is None + assert module.get_priority() is None