mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 22:59:46 +08:00
fix(modules): type service configurations and handle uninitialized defaults
This commit is contained in:
@@ -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]:
|
||||
"""
|
||||
获取已启用的媒体服务器的配置字典
|
||||
|
||||
|
||||
111
tests/test_service_base_contract.py
Normal file
111
tests/test_service_base_contract.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user