mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 22:04:57 +08:00
docs: align touched contracts with Chinese comment rules
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
rTorrent 宿主模块,将下载器操作与结果映射到统一任务接口。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
@@ -21,7 +25,9 @@ from app.schemas.types import (
|
||||
|
||||
|
||||
class RtorrentModule(_DownloaderModuleBase[Rtorrent]):
|
||||
"""rTorrent 下载器模块,负责任务添加、标签和文件状态转换。"""
|
||||
"""
|
||||
rTorrent 下载器模块,负责任务添加、标签和文件状态转换。
|
||||
"""
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""
|
||||
@@ -33,7 +39,9 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]):
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回模块展示名称。"""
|
||||
"""
|
||||
返回模块展示名称。
|
||||
"""
|
||||
return "Rtorrent"
|
||||
|
||||
@staticmethod
|
||||
@@ -58,11 +66,15 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]):
|
||||
return 3
|
||||
|
||||
def stop(self):
|
||||
"""下载器客户端由服务基类管理,本模块无额外停止动作。"""
|
||||
"""
|
||||
下载器客户端由服务基类管理,本模块无额外停止动作。
|
||||
"""
|
||||
pass
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
"""下载器实例由系统配置管理,不声明独立模块开关。"""
|
||||
"""
|
||||
下载器实例由系统配置管理,不声明独立模块开关。
|
||||
"""
|
||||
pass
|
||||
|
||||
def download(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
rTorrent XML-RPC 客户端及 SCGI 传输,负责下载器响应映射。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import io
|
||||
import socket
|
||||
@@ -12,7 +16,25 @@ from app.runtime.log import logger
|
||||
|
||||
|
||||
class TorrentData(TypedDict):
|
||||
"""rTorrent 多调用结果映射,保留现有下载器字典字段。"""
|
||||
"""
|
||||
rTorrent 多调用结果映射,保留现有下载器字典字段。
|
||||
|
||||
:ivar hash: 种子哈希,供任务操作和筛选使用
|
||||
:ivar name: 下载器中的任务名称
|
||||
:ivar total_size: 总字节数
|
||||
:ivar completed: 已下载字节数
|
||||
:ivar dlspeed: 当前下载速率,字节每秒
|
||||
:ivar upspeed: 当前上传速率,字节每秒
|
||||
:ivar state: 启动状态,0 为停止,1 为启动
|
||||
:ivar complete: 完成标记,0 为未完成,1 为完成
|
||||
:ivar save_path: 保存目录
|
||||
:ivar tags: custom1 存储的逗号分隔标签
|
||||
:ivar is_active: 下载器返回的活动状态
|
||||
:ivar is_open: 下载器返回的文件打开状态
|
||||
:ivar ratio: 从千分比转换后的分享率
|
||||
:ivar content_path: 完整内容路径
|
||||
:ivar progress: 下载进度百分比,零大小任务为 0
|
||||
"""
|
||||
|
||||
hash: str
|
||||
name: str
|
||||
@@ -32,7 +54,15 @@ class TorrentData(TypedDict):
|
||||
|
||||
|
||||
class TorrentFileData(TypedDict):
|
||||
"""rTorrent 文件索引和分块进度的字典合同。"""
|
||||
"""
|
||||
rTorrent 文件索引和分块进度的字典合同。
|
||||
|
||||
:ivar id: 文件在种子中的零基索引
|
||||
:ivar name: 种子内文件路径
|
||||
:ivar size: 文件字节数
|
||||
:ivar priority: 下载优先级,0 表示不下载
|
||||
:ivar progress: 按已完成分块计算的百分比
|
||||
"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
@@ -42,7 +72,9 @@ class TorrentFileData(TypedDict):
|
||||
|
||||
|
||||
def _rpc_rows(value: object) -> list[list[object]]:
|
||||
"""校验 XML-RPC 多调用的二维数组,异常仍由调用方原有错误分支处理。"""
|
||||
"""
|
||||
校验 XML-RPC 多调用的二维数组,异常仍由调用方原有错误分支处理。
|
||||
"""
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise TypeError("rTorrent multicall response must be an array")
|
||||
rows: list[list[object]] = []
|
||||
@@ -54,21 +86,27 @@ def _rpc_rows(value: object) -> list[list[object]]:
|
||||
|
||||
|
||||
def _rpc_text(value: object) -> str:
|
||||
"""校验外部返回的文本,不将意外对象强转为路径或标签。"""
|
||||
"""
|
||||
校验外部返回的文本,不将意外对象强转为路径或标签。
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError("rTorrent response field must be text")
|
||||
return value
|
||||
|
||||
|
||||
def _rpc_number(value: object) -> Union[int, float]:
|
||||
"""保留 XML-RPC 数值原值,拒绝不能执行原有算术的字段。"""
|
||||
"""
|
||||
保留 XML-RPC 数值原值,拒绝不能执行原有算术的字段。
|
||||
"""
|
||||
if not isinstance(value, (int, float)):
|
||||
raise TypeError("rTorrent response field must be numeric")
|
||||
return value
|
||||
|
||||
|
||||
def _rpc_integer(value: object) -> int:
|
||||
"""保留比率和分块字段原有的 int 转换,兼容数字字符串。"""
|
||||
"""
|
||||
保留比率和分块字段原有的 int 转换,兼容数字字符串。
|
||||
"""
|
||||
if not isinstance(value, (str, bytes, bytearray, int, float)):
|
||||
raise TypeError("rTorrent response field must be convertible to int")
|
||||
return int(value)
|
||||
@@ -83,7 +121,9 @@ class SCGITransport(xmlrpc.client.Transport):
|
||||
self, host: Union[str, tuple[str, dict[str, str]]], handler: str,
|
||||
request_body: Buffer, verbose: bool = False,
|
||||
) -> tuple[Any, ...]:
|
||||
"""用 SCGI netstring 交换 XML-RPC 报文,并在解析前释放连接。"""
|
||||
"""
|
||||
用 SCGI netstring 交换 XML-RPC 报文,并在解析前释放连接。
|
||||
"""
|
||||
request_body = bytes(request_body)
|
||||
if isinstance(host, tuple):
|
||||
host = host[0]
|
||||
@@ -129,10 +169,14 @@ class SCGITransport(xmlrpc.client.Transport):
|
||||
构造类文件对象用于parse_response
|
||||
"""
|
||||
class _FakeSocket(io.BytesIO):
|
||||
"""为 HTTPResponse 提供内存 socket 接口,复用标准库 XML-RPC 解码。"""
|
||||
"""
|
||||
为 HTTPResponse 提供内存 socket 接口,复用标准库 XML-RPC 解码。
|
||||
"""
|
||||
|
||||
def makefile(self, *args: object, **kwargs: object) -> io.BytesIO:
|
||||
"""返回当前字节流供 HTTPResponse 读取。"""
|
||||
"""
|
||||
返回当前字节流供 HTTPResponse 读取。
|
||||
"""
|
||||
return self
|
||||
|
||||
raw = b"HTTP/1.0 200 OK\r\nContent-Type: text/xml\r\n\r\n" + data
|
||||
@@ -155,7 +199,9 @@ class Rtorrent:
|
||||
password: Optional[str] = None,
|
||||
**kwargs: object,
|
||||
) -> None:
|
||||
"""读取下载器连接配置并建立代理,连接失败时保留待重连状态。"""
|
||||
"""
|
||||
读取下载器连接配置并建立代理,连接失败时保留待重连状态。
|
||||
"""
|
||||
self._proxy = None
|
||||
self._host = ""
|
||||
if host and port:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""异常日志入口应保留错误等级、调用位置和备份接口的安全失败响应。"""
|
||||
"""
|
||||
异常日志入口应保留错误等级、调用位置和备份接口的安全失败响应。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
@@ -14,7 +16,9 @@ from app.runtime.log import LoggerManager, logger
|
||||
|
||||
@pytest.fixture
|
||||
def captured_logs(monkeypatch, caplog):
|
||||
"""捕获真实标准库日志记录与文件消息,避免写入运行目录。"""
|
||||
"""
|
||||
捕获真实标准库日志记录与文件消息,避免写入运行目录。
|
||||
"""
|
||||
console = logging.Logger("exception-contract", level=logging.DEBUG)
|
||||
console.addHandler(caplog.handler)
|
||||
writer = Mock()
|
||||
@@ -26,7 +30,9 @@ def captured_logs(monkeypatch, caplog):
|
||||
|
||||
@pytest.mark.parametrize("include_exception", [None, False, True])
|
||||
def test_exception_logs_error_with_current_exception(captured_logs, include_exception):
|
||||
"""默认记录当前异常,也支持调用方显式关闭或启用堆栈。"""
|
||||
"""
|
||||
默认记录当前异常,也支持调用方显式关闭或启用堆栈。
|
||||
"""
|
||||
caplog, writer = captured_logs
|
||||
original = ValueError("original failure")
|
||||
try:
|
||||
@@ -46,7 +52,9 @@ def test_exception_logs_error_with_current_exception(captured_logs, include_exce
|
||||
|
||||
|
||||
def test_exception_accepts_explicit_exception(captured_logs):
|
||||
"""离开异常处理分支后仍能显式指定需要记录的原异常。"""
|
||||
"""
|
||||
离开异常处理分支后仍能显式指定需要记录的原异常。
|
||||
"""
|
||||
caplog, _writer = captured_logs
|
||||
failure = RuntimeError("explicit failure")
|
||||
logger.exception("saved exception", exc_info=failure)
|
||||
@@ -54,7 +62,9 @@ def test_exception_accepts_explicit_exception(captured_logs):
|
||||
|
||||
|
||||
def test_exception_respects_current_log_threshold(captured_logs, monkeypatch):
|
||||
"""高于 ERROR 的日志阈值继续拦截异常消息。"""
|
||||
"""
|
||||
高于 ERROR 的日志阈值继续拦截异常消息。
|
||||
"""
|
||||
caplog, writer = captured_logs
|
||||
monkeypatch.setattr(log_module, "_effective_context_log_level", lambda: logging.CRITICAL)
|
||||
logger.exception("filtered failure")
|
||||
@@ -70,7 +80,9 @@ def test_exception_respects_current_log_threshold(captured_logs, monkeypatch):
|
||||
(system_endpoint.delete_database_backup, "delete_backup", ("backup.db",)),
|
||||
])
|
||||
async def test_database_failure_preserves_safe_http_error(captured_logs, monkeypatch, handler, operation, args):
|
||||
"""备份服务失败时记录原异常,返回预定 500 且不泄露内部路径。"""
|
||||
"""
|
||||
备份服务失败时记录原异常,返回预定 500 且不泄露内部路径。
|
||||
"""
|
||||
caplog, _writer = captured_logs
|
||||
failure = RuntimeError("private database path /sensitive/database.db")
|
||||
governance = SimpleNamespace(**{operation: Mock(side_effect=failure)})
|
||||
@@ -86,7 +98,9 @@ async def test_database_failure_preserves_safe_http_error(captured_logs, monkeyp
|
||||
|
||||
|
||||
def test_critical_logs_keep_level_and_file_route(captured_logs):
|
||||
"""严重错误入口应将相同内容以 CRITICAL 等级交给控制台和文件。"""
|
||||
"""
|
||||
严重错误入口应将相同内容以 CRITICAL 等级交给控制台和文件。
|
||||
"""
|
||||
caplog, writer = captured_logs
|
||||
logger.critical("critical failure")
|
||||
record = caplog.records[-1]
|
||||
@@ -97,7 +111,9 @@ def test_critical_logs_keep_level_and_file_route(captured_logs):
|
||||
|
||||
@pytest.mark.parametrize("category,resource_id", [("../invalid", 1), ("plugin", 0)])
|
||||
def test_invalid_resource_log_scope_preserves_context(category, resource_id):
|
||||
"""资源标识非法时拒绝绑定,避免污染原有日志上下文。"""
|
||||
"""
|
||||
资源标识非法时拒绝绑定,避免污染原有日志上下文。
|
||||
"""
|
||||
with log_module.bind_log_scope("plugin", 1, "operation", "test") as scope:
|
||||
with pytest.raises(ValueError, match="日志资源标识无效"):
|
||||
with log_module.bind_log_scope(category, resource_id, "bad", "test"):
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""验证 QQ Bot API 的响应边界和消息请求组装。"""
|
||||
"""
|
||||
验证 QQ Bot API 的响应边界和消息请求组装。
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
@@ -10,28 +12,40 @@ from app.modules.qqbot import api
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_token_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""每个用例使用独立缓存,并由 monkeypatch 恢复原进程状态。"""
|
||||
"""
|
||||
每个用例使用独立缓存,并由 monkeypatch 恢复原进程状态。
|
||||
"""
|
||||
monkeypatch.setattr(api, "_cached_token", None)
|
||||
|
||||
|
||||
class _RequestClient:
|
||||
"""提供可控的 QQ API HTTP 响应。"""
|
||||
"""
|
||||
提供可控的 QQ API HTTP 响应。
|
||||
"""
|
||||
|
||||
def __init__(self, response: object) -> None:
|
||||
"""保存本次测试的模拟响应。"""
|
||||
"""
|
||||
保存本次测试的模拟响应。
|
||||
"""
|
||||
self.response = response
|
||||
|
||||
def post_res(self, *_args: object, **_kwargs: object) -> object:
|
||||
"""返回预设的 POST 响应。"""
|
||||
"""
|
||||
返回预设的 POST 响应。
|
||||
"""
|
||||
return self.response
|
||||
|
||||
def get_res(self, *_args: object, **_kwargs: object) -> object:
|
||||
"""返回预设的 GET 响应。"""
|
||||
"""
|
||||
返回预设的 GET 响应。
|
||||
"""
|
||||
return self.response
|
||||
|
||||
|
||||
def test_get_access_token_caches_and_reuses_valid_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""有效的令牌响应应被缓存,并在有效期内复用。"""
|
||||
"""
|
||||
有效的令牌响应应被缓存,并在有效期内复用。
|
||||
"""
|
||||
api.clear_token_cache()
|
||||
response = SimpleNamespace(json=lambda: {"access_token": "token", "expires_in": 7200})
|
||||
client = Mock(return_value=_RequestClient(response))
|
||||
@@ -46,7 +60,9 @@ def test_get_access_token_caches_and_reuses_valid_token(monkeypatch: pytest.Monk
|
||||
def test_get_access_token_rejects_invalid_response(
|
||||
monkeypatch: pytest.MonkeyPatch, payload: object,
|
||||
) -> None:
|
||||
"""令牌响应不是对象或令牌不是字符串时应失败。"""
|
||||
"""
|
||||
令牌响应不是对象或令牌不是字符串时应失败。
|
||||
"""
|
||||
api.clear_token_cache()
|
||||
response = SimpleNamespace(json=lambda: payload)
|
||||
monkeypatch.setattr(api, "RequestUtils", Mock(return_value=_RequestClient(response)))
|
||||
@@ -56,7 +72,9 @@ def test_get_access_token_rejects_invalid_response(
|
||||
|
||||
|
||||
def test_api_request_validates_json_and_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""API 请求应收窄 JSON 对象,并将 HTTP 错误转换为异常。"""
|
||||
"""
|
||||
API 请求应收窄 JSON 对象,并将 HTTP 错误转换为异常。
|
||||
"""
|
||||
response = SimpleNamespace(json=lambda: {"url": "wss://gateway"}, status_code=200)
|
||||
monkeypatch.setattr(api, "RequestUtils", Mock(return_value=_RequestClient(response)))
|
||||
assert api.get_gateway_url("token") == "wss://gateway"
|
||||
@@ -68,7 +86,9 @@ def test_api_request_validates_json_and_status(monkeypatch: pytest.MonkeyPatch)
|
||||
|
||||
|
||||
def test_send_message_builds_passive_and_proactive_requests(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""统一发送接口应按消息类型和 msg_id 选择正确的请求路径。"""
|
||||
"""
|
||||
统一发送接口应按消息类型和 msg_id 选择正确的请求路径。
|
||||
"""
|
||||
request = Mock(return_value={"id": "sent"})
|
||||
monkeypatch.setattr(api, "_api_request", request)
|
||||
|
||||
@@ -79,6 +99,8 @@ def test_send_message_builds_passive_and_proactive_requests(monkeypatch: pytest.
|
||||
|
||||
|
||||
def test_proactive_message_rejects_blank_content() -> None:
|
||||
"""主动消息不能发送空白内容。"""
|
||||
"""
|
||||
主动消息不能发送空白内容。
|
||||
"""
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
api.send_proactive_c2c_message("token", "user", " ")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""验证 rTorrent XML-RPC 返回值映射及异常响应的隔离。"""
|
||||
"""
|
||||
验证 rTorrent XML-RPC 返回值映射及异常响应的隔离。
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -9,7 +11,9 @@ from app.modules.rtorrent.rtorrent import Rtorrent
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""构造无网络客户端,所有远端调用均由 Mock 接管。"""
|
||||
"""
|
||||
构造无网络客户端,所有远端调用均由 Mock 接管。
|
||||
"""
|
||||
result = Rtorrent.__new__(Rtorrent)
|
||||
result._proxy = Mock()
|
||||
return result
|
||||
@@ -17,13 +21,17 @@ def client():
|
||||
|
||||
@pytest.fixture
|
||||
def torrent_row():
|
||||
"""提供与 d.multicall2 字段顺序一致的种子记录。"""
|
||||
"""
|
||||
提供与 d.multicall2 字段顺序一致的种子记录。
|
||||
"""
|
||||
return ["ABC", "movie", 1000, 250, 10, 20, 1, 0,
|
||||
"/downloads", "movie, keep", 1, 1, "1500", "/downloads/movie"]
|
||||
|
||||
|
||||
def test_torrent_mapping_keeps_progress_ratio_and_filters(client, torrent_row):
|
||||
"""有效响应保留字段原值、百分比和千分比,过滤不改变记录。"""
|
||||
"""
|
||||
有效响应保留字段原值、百分比和千分比,过滤不改变记录。
|
||||
"""
|
||||
client._proxy.d.multicall2.return_value = [torrent_row]
|
||||
torrents, error = client.get_torrents(ids="abc", tags=["keep"])
|
||||
assert error is False
|
||||
@@ -38,7 +46,9 @@ def test_torrent_mapping_keeps_progress_ratio_and_filters(client, torrent_row):
|
||||
|
||||
|
||||
def test_zero_size_and_tuple_rows_remain_supported(client, torrent_row):
|
||||
"""XML-RPC 数组支持列表和元组,零大小任务保持零进度。"""
|
||||
"""
|
||||
XML-RPC 数组支持列表和元组,零大小任务保持零进度。
|
||||
"""
|
||||
torrent_row[2] = 0
|
||||
torrent_row[12] = 0
|
||||
client._proxy.d.multicall2.return_value = (tuple(torrent_row),)
|
||||
@@ -49,21 +59,27 @@ def test_zero_size_and_tuple_rows_remain_supported(client, torrent_row):
|
||||
|
||||
@pytest.mark.parametrize("payload", [None, 123, "wrong", [123], [["short"]]])
|
||||
def test_malformed_multicall_keeps_error_contract(client, payload):
|
||||
"""错误的远端数组仍返回原有失败标记,不泄漏部分结果。"""
|
||||
"""
|
||||
错误的远端数组仍返回原有失败标记,不泄漏部分结果。
|
||||
"""
|
||||
client._proxy.d.multicall2.return_value = payload
|
||||
assert client.get_torrents() == ([], True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index,value", [(0, 12), (2, "1000"), (9, ["tag"]), (12, {"invalid": True})])
|
||||
def test_invalid_torrent_fields_keep_error_contract(client, torrent_row, index, value):
|
||||
"""身份、算术和标签字段不接受不符合 XML-RPC 合同的类型。"""
|
||||
"""
|
||||
身份、算术和标签字段不接受不符合 XML-RPC 合同的类型。
|
||||
"""
|
||||
torrent_row[index] = value
|
||||
client._proxy.d.multicall2.return_value = [torrent_row]
|
||||
assert client.get_torrents() == ([], True)
|
||||
|
||||
|
||||
def test_file_chunk_progress_accepts_numeric_strings(client):
|
||||
"""文件分块计数继续兼容字符串,并避免零分母。"""
|
||||
"""
|
||||
文件分块计数继续兼容字符串,并避免零分母。
|
||||
"""
|
||||
client._proxy.f.multicall.return_value = [
|
||||
["one", 100, 1, "2", "4"], ["two", 0, 0, 0, 0],
|
||||
]
|
||||
@@ -76,7 +92,9 @@ def test_file_chunk_progress_accepts_numeric_strings(client):
|
||||
|
||||
|
||||
def test_tag_operations_preserve_merge_remove_and_empty_response(client):
|
||||
"""标签的合并、删除和空返回兼容原行为。"""
|
||||
"""
|
||||
标签的合并、删除和空返回兼容原行为。
|
||||
"""
|
||||
client._proxy.d.custom1.return_value = " old, keep "
|
||||
assert client.set_torrents_tag("ABC", ["new"])
|
||||
assert set(client._proxy.d.custom1.set.call_args.args[1].split(",")) == {"old", "keep", "new"}
|
||||
|
||||
Reference in New Issue
Block a user