fix(transmission): normalize raw fallback progress (#7017)

* fix(transmission): normalize raw fallback progress

* fix(transmission): satisfy type ratchet for progress helper

Annotate and normalize the fallback progress helper so the stricter V3 typing gate does not regress.

Co-authored-by: Codex <codex@openai.com>

* docs: sync mypy metric after progress fix

---------

Co-authored-by: ranzhigg <ranzhigg@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
ranzhigg
2026-10-16 12:27:33 +08:00
committed by GitHub
parent 225da1a7f0
commit 383fb82b89
5 changed files with 101 additions and 12 deletions

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from app.domain import torrent as torrent_rules
from app.domain.metainfo import MetaInfo
@@ -28,6 +28,22 @@ _TRANSMISSION_PAUSED_STATES = {
}
def _normalize_transmission_progress(
torrent_data: Any, attr_getter: Callable[..., Any]
) -> float:
"""读取 Transmission 任务进度并统一为百分比。"""
progress = attr_getter(torrent_data, "progress")
if progress is not None:
return float(progress or 0)
percent_done = attr_getter(torrent_data, "percent_done")
if percent_done is None:
return 0
# transmission-rpc 的 percent_done 原始值范围是 0..1;
# 兼容旧适配器时仍可能收到已经归一化的百分比。
percent_value = float(percent_done)
return percent_value * 100 if 0 <= percent_value <= 1 else percent_value
class TransmissionModule(_DownloaderModuleBase[Transmission]):
"""Transmission 下载器模块,负责任务添加、标签和文件选择。"""
@@ -252,12 +268,6 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]):
continue
return None
def __get_torrent_progress(torrent_data) -> float:
"""
获取任务进度。
"""
return __get_torrent_attr(torrent_data, "progress", "percent_done") or 0
def __get_torrent_size(torrent_data) -> int:
"""
获取任务大小。
@@ -305,7 +315,7 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]):
Path(torrent_data.download_dir), downloader_name
) if getattr(torrent_data, "download_dir", None) else None,
content_path=self.normalize_return_path(torrent_path, downloader_name),
progress=__get_torrent_progress(torrent_data),
progress=_normalize_transmission_progress(torrent_data, __get_torrent_attr),
size=__get_torrent_size(torrent_data),
state=self.__normalize_torrent_state(torrent_data.status),
dlspeed=size_tools.format_compact_size(dlspeed),

View File

@@ -108,7 +108,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 371,964 行 | 本次重新扫描物理行数,排除 `app/plugins/**`;74 个文件超过 1,000 行,12 个超过 2,000 行;源码尺寸仅作观察 |
| 长方法 | 309 个超过 80 行 | 本次 AST 扫描排除 `app/plugins/**`;61 个超过 150 行,17 个超过 250 行;新增函数仍受 C901/嵌套深度门禁约束 |
| 全量 mypy 历史债务 | 9,043 / 490 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项,逐文件逐错误码无增长 |
| 全量 mypy 历史债务 | 9,041 / 490 文件 | 通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项,逐文件逐错误码无增长 |
| Ruff 历史诊断 | 100 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |

View File

@@ -106,7 +106,7 @@
"app/modules/slack/__init__.py:SlackModule.message_parser": 16,
"app/modules/slack/slack.py:Slack.send_medias_msg": 18,
"app/modules/transmission/__init__.py:TransmissionModule.download": 18,
"app/modules/transmission/__init__.py:TransmissionModule.list_torrents": 26,
"app/modules/transmission/__init__.py:TransmissionModule.list_torrents": 25,
"app/modules/trimemedia/api.py:Api.request": 16,
"app/modules/ugreen/ugreen.py:Ugreen.get_tv_episodes": 21,
"app/modules/wechat/__init__.py:WechatModule.message_parser": 20,

View File

@@ -2175,8 +2175,8 @@
"empty-body": 1,
"no-any-return": 1,
"no-redef": 2,
"no-untyped-call": 9,
"no-untyped-def": 7,
"no-untyped-call": 8,
"no-untyped-def": 6,
"str-bytes-safe": 1,
"type-arg": 6
},

View File

@@ -5,8 +5,11 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
def _load_downloader_base():
"""隔离宿主服务依赖,加载真实下载器路径映射实现。"""
repo_root = Path(__file__).resolve().parents[1]
app_module = types.ModuleType("app")
@@ -29,11 +32,15 @@ def _load_downloader_base():
mixins_module = types.ModuleType("app.runtime.reload")
class StorageSchema(Enum):
"""提供路径映射识别所需的存储类型。"""
Local = "local"
Rclone = "rclone"
SMB = "smb"
class _ConfigReloadMixin:
"""隔离配置热重载的运行时依赖。"""
pass
class _Logger:
@@ -44,16 +51,21 @@ def _load_downloader_base():
pass
class _ServiceConfigHelper:
"""使用空配置隔离下载器之外的服务初始化。"""
@staticmethod
def get_downloader_configs():
"""不从真实配置加载下载器。"""
return []
@staticmethod
def get_notification_configs():
"""不从真实配置加载通知渠道。"""
return []
@staticmethod
def get_mediaserver_configs():
"""不从真实配置加载媒体服务器。"""
return []
schema_types_module.StorageSchema = StorageSchema
@@ -124,6 +136,7 @@ def _load_downloader_base():
def _load_transmission_module():
"""隔离下载器客户端与宿主依赖,加载真实 Transmission 模块。"""
repo_root = Path(__file__).resolve().parents[1]
app_module = types.ModuleType("app")
@@ -163,10 +176,15 @@ def _load_transmission_module():
torrentool_torrent_module = types.ModuleType("torrentool.torrent")
class _ModuleBase:
"""隔离模块注册及生命周期管理。"""
pass
class _DownloaderBase:
"""保留下载器模块的泛型基类入口。"""
def __class_getitem__(cls, _item):
"""让隔离基类接受生产代码的客户端类型参数。"""
return cls
class _DownloaderModuleBase(_ModuleBase, _DownloaderBase):
@@ -202,15 +220,24 @@ def _load_transmission_module():
return TorrentQueryStatus.ALL
class _TransferTorrent:
"""保留整理任务构造时传入的字段,供断言检查。"""
def __init__(self, **kwargs):
"""保存模块实际返回的整理任务字段。"""
self.__dict__.update(kwargs)
class _DownloadingTorrent:
"""保留下载中任务构造时传入的字段,供断言检查。"""
def __init__(self, **kwargs):
"""保存模块实际返回的下载中任务字段。"""
self.__dict__.update(kwargs)
class _DownloaderTorrent:
"""保留统一下载任务构造时传入的字段,供断言检查。"""
def __init__(self, **kwargs):
"""保存模块实际返回的统一下载任务字段。"""
self.__dict__.update(kwargs)
class _DownloaderFile:
@@ -227,10 +254,14 @@ def _load_transmission_module():
return instance
class TorrentStatus(Enum):
"""提供下载器历史查询入口使用的状态。"""
TRANSFER = "transfer"
DOWNLOADING = "downloading"
class TorrentQueryStatus(Enum):
"""提供统一下载器查询入口使用的状态。"""
ALL = "all"
TRANSFER = "transfer"
DOWNLOADING = "downloading"
@@ -238,22 +269,32 @@ def _load_transmission_module():
PAUSED = "paused"
class DownloadTaskState(Enum):
"""提供统一下载任务返回值使用的状态。"""
DOWNLOADING = "downloading"
PAUSED = "paused"
COMPLETED = "completed"
class _Logger:
"""隔离 Transmission 测试的运行日志输出。"""
def debug(self, *_args, **_kwargs):
"""忽略测试范围外的调试日志。"""
pass
def info(self, *_args, **_kwargs):
"""忽略测试范围外的普通日志。"""
pass
def error(self, *_args, **_kwargs):
"""忽略测试范围外的错误日志。"""
pass
class _MetaInfo:
"""提供下载任务展示所需的最小媒体元数据。"""
def __init__(self, name):
"""保留种子名称并将未解析的媒体字段置空。"""
self.name = name
self.year = None
self.season_episode = ""
@@ -272,7 +313,10 @@ def _load_transmission_module():
return str(value)
class _FileCache:
"""隔离真实文件缓存,避免读取运行时状态。"""
def get(self, *_args, **_kwargs):
"""固定返回缓存未命中。"""
return None
transmission_client_module.Transmission = object
@@ -385,6 +429,7 @@ def _build_base(path_mapping):
def _build_transmission_module(server):
"""注入离线下载器及固定路径映射,避免初始化真实服务。"""
module = TransmissionModule.__new__(TransmissionModule)
module.get_instances = MagicMock(return_value={"tr": server})
module.get_instance = MagicMock(return_value=server)
@@ -486,6 +531,40 @@ def test_completed_torrents_return_moviepilot_accessible_path():
assert torrents[0].content_path == "/media/video/downloads/TV/Show.S01E01.mkv"
@pytest.mark.parametrize(
("progress_fields", "expected_progress"),
[
({"percent_done": 0}, 0),
({"percent_done": 0.42}, 42),
({"percent_done": 1}, 100),
({"percent_done": 42}, 42),
({"percent_done": 100}, 100),
({"progress": 42, "percent_done": 1}, 42),
({"progress": 0, "percent_done": 1}, 0),
({"progress": None, "percent_done": 1}, 100),
({}, 0),
],
)
def test_transmission_progress_uses_percentage_contract(progress_fields, expected_progress):
"""原始进度须转换为百分比,已有 progress(含零值)须优先保留。"""
server = MagicMock()
server.get_completed_torrents.return_value = [
SimpleNamespace(
name="Movie",
download_dir="/mnt/raid5/home_lt999lt/video/downloads/movie",
hashString="hash-tr",
labels=[],
status="seeding",
**progress_fields,
)
]
module = _build_transmission_module(server)
torrents = module.list_torrents(status=TransmissionTorrentStatus.TRANSFER)
assert torrents[0].progress == expected_progress
def test_hash_lookup_return_moviepilot_accessible_path():
"""Transmission 按 Hash 查询时返回的路径字段均应完成路径映射。"""
server = MagicMock()