Commit Graph
2 Commits
Author SHA1 Message Date
snowy-yang 9910e4dbec refactor: _fetch_via_http 拆解(94 行 / 圈 21 → 46 行 / 圈 5)
为什么:_fetch_via_http 是全仓内置下载通道的落盘实现,也是本文件此前最大的
圈复杂度所在。94 行的单体里叠着三段互不相关的职责——Range 起点校验、流式写盘
与体积上限、HTTP 错误分类——还共享一个 try/except/finally。上一轮拆
download_music_file 时它是唯一没动的重函数。

改法:拆成 7 个具名成员,主函数只留四步编排(构造 Range 头 → 发请求 →
定追加/重写并校验分片起点 → 流式落盘)。
  _range_headers                  续传时请求「起点之后的全部字节」
  _resolve_write_mode             续传且服务器确实给 206 才追加,否则从头重写
  _verify_content_range           校验 206 分片起点;无法确认就清残片从头下
  _stream_body                    逐块写盘 + 取消检查 + 体积硬上限(500MB)
  _safe_progress                  上报进度,回调异常吞掉(原两处 try/except 收敛)
  _http_status                    从 HTTPError 取状态码
  _is_deterministic_client_error  4xx 判定(408 例外,唯一保留可重试的 4xx)

复杂度(同口径 AST 实测,HEAD 与工作区用同一脚本):
  _fetch_via_http     94 行 / 圈 21 / 嵌套 7  →  46 行 / 圈 5 / 嵌套 3
  文件内最大圈复杂度    21(当时就是它)        →  16(余下最大是 download_music_file)
  文件行数 710 → 753(+43,全是新签名与 docstring)· 函数数 19 → 26
  口径说明:上一轮口头报的「圈 35」用的是另一脚本(另计入布尔运算与比较),
  本轮 HEAD 同口径实测为 21。以本轮为准——HEAD 与重构后由同一脚本测出,可比。

等价性证据(两道,互相独立):
1. 反向注入验证:把 HEAD 版 downloader 注入成 music_downloader.downloader,用同一套
   tests/test_downloader.py 跑 → 30 例**全过**。证明这批测试忠实编码了旧行为,
   因而对新实现构成等价性验证(含本次新增的 5xx 用例,它在旧实现上同样通过)。
2. AST 字面量多重集比对 + 逐条位置定位:数值/布尔 26 种、字符串 80 种,
   差异全部归因,且**没有一条用户可见文案或阈值被改动**:
     206  2→1:旧的两段独立 if(`!= 206` 归零 mode、`== 206` 校验起点)合并进
               _resolve_write_mode 的一个判断
     0   26→24:两处 progress 的 `or 0` 收敛成 expected_size(-1);
               两处 206 判断各含一个 `> 0` 合并成一个 `<= 0`(-1)
     'wb' 2→1:初始赋值与条件改写两处合并成一个 return
   500MB 阈值、各条错误文案原样保留。

重写条件表达式必须补分支穷举(字面量审计不管这一层),已逐路核对:
  resume_from <= 0                     → 'wb'(旧:不进任何 if,保持初始 'wb')
  resume_from > 0 且 status != 206     → 'wb'(旧:第一个 if 命中)
  resume_from > 0 且 status == 206     → 校验起点 + 'ab'(旧:第二个 if 命中)
三路与旧实现一致。

刻意保留的语义(是决定,不是疏漏):
- 超限清理仍**延后到 with 退出后**——Windows 无法删除仍被打开的文件,原实现在
  打开中 unlink 被静默吞掉,残片会留在磁盘上。
- 进度回调异常一律吞掉,绝不影响下载主流程。
- 4xx 里 408 是唯一例外(保留可重试);416 与「206 头被剥离解析不出起点」同口径
  清残片从头下(盲追加拼出的是坏文件)。
- 非 416/4xx 的错误走**裸 raise**(不用 `raise e`),保持原 traceback 不被改动。

测试:新增 1 例 test_5xx_is_retried_and_keeps_part —— 5xx 是服务端瞬时故障而非
确定性失败,须可重试且保留 .part 供续传。这条路径此前无覆盖,且它正是
_is_deterministic_client_error 与 4xx 的分界所在。

顺带记录一个验证工具自身的坑(第一次跑反向注入时 30 例全红,看似「测试不忠实」,
实为注入没生效、测试静默走了真实网络,报错为 Cannot connect to host cdn.test):
只覆盖 sys.modules['music_downloader.downloader'] 不够——pytest 的 monkeypatch
解析目标字符串走 _pytest.monkeypatch.resolve(),是「逐级 getattr 父包」而非纯
sys.modules 查找,包的 downloader 属性仍指向真模块。必须同时 setattr 父包属性。
教训:等价性验证的**工具本身**也要能证伪,否则结论会 180 度反转。

验证:ruff 全过 · pyright 0/0 · pytest 275 passed(274→275)· 行尾 i/lf w/lf
与仓库一致 · 生产 data/ 未被触碰(全部用例走 tmp_path 沙箱)。
2026-09-27 08:05:44 +08:00
snowy-yang 8716617d5d refactor: download_music_file 拆解(205 行 / 圈 47 → 125 行 / 圈 20)
为什么:download_music_file 是**唯一**的音频落盘路径(单曲 worker、音乐批量 ZIP
都经它),205 行 / 圈复杂度 47 / 嵌套 7 的单体里叠着缓存校验、断点续传、内容嗅探、
体积上限、取消语义、重试退避、元数据限流七件事,却**零直接测试覆盖**
(test_zip_workers 里只有一个 stub)。任何一处被改坏都不会有测试报警。

改法:拆成 8 个具名方法,主函数只留「5 步管线 + 重试策略」:
  _cached_result          锁内复查缓存(命中即返回,坏缓存删除重下)
  _promote_complete_part  已下完未改名的 .part 直接转正,不发起网络请求
  _resume_offset          断点续传起点,并清理没有续传价值的残片
  _transfer               占全局名额 + 选通道(aria2c / 内置 aiohttp)
  _size_mismatch_error    完整性判定(已知期望精确相等 / 未知期望至少非空)
  _publish                嗅探 → 原子转正 → 写元数据
  _write_metadata_locked  标签信号量内的元数据写入(原三处重复的 with 收敛)
  _backoff_wait           退避秒数(原五处重复的 0.5*(attempt+1) 收敛)

复杂度(同口径 AST 实测):
  download_music_file  205 行 / 圈 47 / 嵌套 7  →  125 行 / 圈 20 / 嵌套 4
                       (125 行里 26 行是 docstring,正文 87 行)
  文件内嵌套 >=5 层     2 → 1(余下的是本次未动的 _fetch_via_http)
  文件内最大圈复杂度     47 → 35(同样落在未动的 _fetch_via_http 上)
  文件行数             614 → 710(+96,全是新签名与 docstring)

等价性证据(两道,互相独立):
1. 29 例行为契约测试**在未改动的原始实现上一次性全绿**(先写测试、后动代码;改完
   再跑仍全绿)——这批测试因此忠实编码了旧行为,构成等价性验证,而不是「照着新
   实现写的新行为测试」。断言只依赖返回值 / 磁盘上的文件 / 发往网络的请求,不引用
   任何内部函数名,故可跨后续重构复用。
2. AST 字面量多重集比对 + **逐条位置定位**:数值/布尔 27 种、字符串 80 种。差异
   全部归因,且**没有一条用户可见文案或阈值被改动**——
     0   +2:_resume_offset 的三个 return 0 取代原 resume_from = 0
     0.0 +1、0.5 −3、1 −3:四处重复的 0.5*(attempt+1) 收敛进 _backoff_wait
     None +16:新增方法的 6 处类型标注 + 4 处 return None + 2 处 is not None
     ''  +1:_size_mismatch_error 的内部哨兵(非用户可见)
   续传起点逻辑另做逐例核对:promote 命中 / existing<=0 / 超尺寸 / 部分 四路等价。

刻意保留的语义(是决定,不是疏漏):
- 退避睡眠仍在文件锁外执行——同名并发任务不陪睡。
- 缓存命中时若 write_meta 仍按本次请求重跑一次标签写入(幂等);write_meta=False
  则不写。
- 已转正(os.replace 之后)才发现取消时**不回滚文件**,只跳过元数据——原行为如此,
  已用 test_cancel_after_promote_keeps_file_but_skips_metadata 固化。
- 未知期望尺寸(file_size=0)走「非空即通过」+ 内容嗅探兜底;已知期望尺寸要求
  **精确相等**(放宽到容差会交付截断的坏文件还照扣次数)。
- 缓存有效性用 st_size != file_size(而非 >=):跨账号音质降级 / 上游变更都会让
  同 ID 同音质的实际文件不同,命中即返回会把旧文件当本次结果交付。

测试:新增 tests/test_downloader.py(29 例)——正常落盘与 .part 转正、空分块跳过、
进度回调(起点 / 每块 / 异常吞掉)、缓存命中不发请求、坏缓存重下、0 字节缓存拒绝、
续传发 Range 并追加、服务器忽略 Range 时重写、完整残片免网络转正、超尺寸残片丢弃、
206 起点错位 / 206 缺 Content-Range / 416 三路从头重下、CDN 4xx 不重试、408 可重试、
不完整保留 .part、下轮续传、未知尺寸非音频拒绝、体积上限、五种取消路径、元数据开关。

验证:ruff 全过 · pyright 0/0 · pytest 274 passed(245→274)· 生产 data/ 未被触碰
(全部用例走 tmp_path 沙箱)。
2026-09-27 07:56:01 +08:00