docs: align touched contracts with Chinese comment rules

This commit is contained in:
jxxghp
2026-10-17 15:54:26 +08:00
parent 7b6441bfe5
commit 136a094055
5 changed files with 156 additions and 42 deletions

View File

@@ -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(

View File

@@ -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:

View File

@@ -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"):

View File

@@ -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", " ")

View File

@@ -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"}