From 136a0940551ea192e4a6e58298780094bc9cd971 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 15:54:26 +0800 Subject: [PATCH] docs: align touched contracts with Chinese comment rules --- app/modules/rtorrent/__init__.py | 20 ++++++++-- app/modules/rtorrent/rtorrent.py | 66 +++++++++++++++++++++++++++----- tests/test_log_exception.py | 32 ++++++++++++---- tests/test_qqbot_api.py | 44 +++++++++++++++------ tests/test_rtorrent_responses.py | 36 ++++++++++++----- 5 files changed, 156 insertions(+), 42 deletions(-) diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index b7072d63c..6338fab22 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -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( diff --git a/app/modules/rtorrent/rtorrent.py b/app/modules/rtorrent/rtorrent.py index f126d0368..0512f55e1 100644 --- a/app/modules/rtorrent/rtorrent.py +++ b/app/modules/rtorrent/rtorrent.py @@ -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: diff --git a/tests/test_log_exception.py b/tests/test_log_exception.py index 0c8055c87..cc950d1d9 100644 --- a/tests/test_log_exception.py +++ b/tests/test_log_exception.py @@ -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"): diff --git a/tests/test_qqbot_api.py b/tests/test_qqbot_api.py index 4232ddef7..36210689b 100644 --- a/tests/test_qqbot_api.py +++ b/tests/test_qqbot_api.py @@ -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", " ") diff --git a/tests/test_rtorrent_responses.py b/tests/test_rtorrent_responses.py index ab0d50d04..b6c8a2406 100644 --- a/tests/test_rtorrent_responses.py +++ b/tests/test_rtorrent_responses.py @@ -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"}