
Python 报错速查:AttributeError / FileNotFoundError / IndentationError 到底在说什么本文是《手写 FASTA 解析器》系列第 2 篇上篇:手写FASTA解析器踩了9个坑 —— 从0条记录到3条的完整调试实录中篇:Python报错速查 —— AttributeError / FileNotFoundError / IndentationError 到底在说什么我是生信方向研一新手,Python 零基础起步,研究方向是小鼠VNTR(可变数目串联重复)变异分析。下篇:同样的解析逻辑,字典版和 yield 生成器版差了 4 万倍内存为了搞懂序列文件是怎么被读进程序的,我从零手写了一个 FASTA 解析器 ——改了 9 版才对。这个系列记录这 9 版每一次错在哪、现象是什么、病根在哪。所有性能数字都是实测值,不是估算。上篇讲的前 5 版,错误多少还会报错。本篇这四版是另一个物种:它们全都不报错,只是安静地给你一个错结果。其中最阴的一版,assert len(recs) 3照样通过,而三条序列全部错位。另一版三个测试全绿,但在真实数据上要跑 85 天。一、四版沉默的错误第 6 版最阴的一版 —— 数据错位assert却通过了seq_idline[1:].split()[0]# 错:先换标签records[seq_id].join(chunks)# 再结账 - 旧货贴了新标签输出共读到 3 条记录 VNTR_01 0 bp VNTR_02 69 bp VNTR_03 32 bp --- assert len(recs) 3 没有报错,程序自认为成功 ---标签实际装进去的应该装VNTR_01空0 bp69 bpVNTR_0269 bp← 这是 VNTR_01 的序列40 bpVNTR_0332 bp碰巧对32 bp序列整体往后挪了一位被贴上了错的标签。放到真实分析里这意味着 VNTR_02 的拷贝数报告写的其实是 VNTR_01 的数据 ——论文发出去都不知道错在哪。道理结账必须用旧 ID。一旦执行seq_id 新ID旧 ID 就被覆盖、再也找不回来了篮子里装的还是旧货标签却已经换成新的。所以先结账、再开新里的先后不是风格问题是数据正确性问题。附带一个自查信号seq_id ...的下一行写if seq_id is not None:这个判断永远成立刚赋完值怎么可能是None。如果一个if永远为真说明它被挪错位置了。顺便说说断言怎么写这一版能骗过assert len(recs) 3说明断言只查数量不查内容等于没查assertlen(recs)3,f记录数不对,实际{len(recs)}条assertlen(recs[VNTR_01])69,fVNTR_01 长度不对,实际{len(recs[VNTR_01])}assertNonenotinrecs,出现了 None 键,说明 ID 没取到写测试的原则断言要卡在错了就一定露馅的地方。条数容易碰巧对上长度和键名不会。第 7 版主流程终于对了但空文件冒出鬼记录test_vntr.fa - 3 条,69/40/32 ✓ test_empty.fa - {None: } ✗ 空文件却读出 1 条黑话边界条件edge case。大白话正常数据谁都能跑对程序是死在边角料上的。空文件、只有一条记录、ID 重复、序列带奇怪字符 —— 真实的重测序数据里这些全都会出现。病因循环外那句收尾结账是无条件执行的。文件空的时候根本没有记录要结账它却照样结了一次用的是初始值Nonerecords{}seq_idNonechunks[]# 文件是空的 - for 循环体一行都没执行 - 三个变量原封不动records[seq_id].join(chunks)# seq_id 还是 Noneprint(records)# {None: }修法 —— 给它也加一道门ifseq_idisnotNone:# 加这一行records[seq_id].join(chunks)规律看到None键就是结账没设门。注意是键是None值是空字符串。第 8 版三个测试全绿缩进差 4 格 慢 125 倍我把收尾结账的if加对了但缩进多了 4 格它跑进 for 循环里面去了forlineinf:...ifseq_idisnotNone:# 12 格:在循环里 - 执行 1 万次ifseq_idisnotNone:# 8 格:和 for 平齐 - 执行 1 次三个测试全部通过结果完全正确。换成 1 万行的文件收尾在循环里: 0.500 秒 收尾在循环外: 0.004 秒 ← 快 125 倍,结果一模一样为什么缩进决定这句话被执行多少次n0foriinrange(10000):nn1# 缩进在 for 里面print(n)# 10000n0foriinrange(10000):passnn1# 缩进和 for 平齐print(n)# 1同一句代码只因为缩进不同执行次数差 1 万倍。而且每次的工作量还在长大 —— 放在循环里等于每读一行都把已攒的所有碎片重拼一遍在循环外: join 执行 1 次,搬运 600,000 个字符 在循环里: join 执行 10000 次,搬运 3,000,300,000 个字符搬运量差 5000 倍。根本原因是字符串不可变immutable.join()没法在原字符串上追加每次都要另开一块新内存、把所有内容重抄一遍。黑话时间复杂度time complexity。O(n)文件大 10 倍 → 慢 10 倍可接受O(n²)文件大 10 倍 → 慢100 倍灾难按这个趋势外推到小鼠参考基因组 GRCm39约 4300 万行写法推算耗时收尾在循环里约85 天收尾在循环外约17 秒纯按复杂度外推实际上内存会先撑不住作业在集群上会被 SLURM 因超内存 kill。性能三问这行在哪层循环里会执行几次每次干多少活第 9 版else被抢走了append成了死代码我想加重复 ID 打印警告顺手多包了一层if line:ifnotline:continueifline:# ← 新加的,缩进 12ifline.startswith():# ← 缩进 16...else:# ← 缩进 12,和 if line: 对齐chunks.append(line)# 所以它配的是 if line:!结果共读到 3 条记录 VNTR_01 0 bp VNTR_02 0 bp VNTR_03 0 bp条数对序列全空 —— 篮子一次都没装进东西。else跟谁配对看缩进和哪个if对齐。我的else和if line:平齐所以它配的是if line:不再是if line.startswith()。而if line:永远为真—— 上一行if not line: continue已经把空行全挡掉了。永远为真的if它的else就永远执行不到。forxin[CAGCAG,VNTR_01]:ifx:# 永远为真ifx.startswith():print(x,- 标题行分支)else:print(x,- 序列行分支)# 永远到不了# 输出只有 VNTR_01 - 标题行分支# CAGCAG 那一轮什么都没打印,它掉进了 if line: 的空档里黑话不可达分支unreachable branch死代码dead code。大白话写了但永远跑不到的代码。Python 不会警告你它就静静待着不干活。附加坑参数写死静默处理错样本整个过程中我有几版把路径写死在open()里defread_fasta(path):withopen(D:/work/test_vntr.fa,encodingutf-8)asf:# 参数 path 没人理后果有多离谱传 test_dup.fa - VNTR_01/02/03 (69/40/32) 传 test_empty.fa - VNTR_01/02/03 (69/40/32) 传一个根本不存在的文件 - VNTR_01/02/03 (69/40/32)连不存在.fa都能返回 3 条记录。这类 bug 在真实分析里是灾难级的你以为在处理样本 B实际一直在读样本 A跑出来的所有结果都指向错误的样本而且全程不报错。二、报错速查表读法最后一行 病名倒数第二行 案发行号报错前那句可疑输出往往才是病根。报错病名先查什么AttributeError: str object has no attribute items对字符串用了字典的方法是不是把字典整体覆盖成字符串了FileNotFoundError找不到文件cwd 站对了吗十次九次是这个不是文件没了IndentationError: unindent does not match...同块内缩进格数不一致这行比上下行多/少几格TabError: inconsistent use of tabs and spacesTab 和空格混用肉眼看不出统一用空格AssertionError自检没过看 assert 后面那句话给的实际值KeyError字典里没这个键键名拼写、是不是还没存进去关于缩进Python 的两条规则同一个代码块里每一行的缩进必须完全一样差一个空格都报错块缩进多少格无所谓只要比外层深 —— 4 格、8 格甚至 1 格都合法# IndentationError: unindent does not match any outer indentation levelifTrue:a1b2# TabError: inconsistent use of tabs and spaces in indentationifTrue:a1b2# 这行是 TabTabError最阴因为肉眼看着一模一样宽。规范PEP 8是4 个空格编辑器里统一用空格别混。三、两个高价值 debug 套路这两条帮我定位了 9 版里的 4 版套路 1某变量始终保持初始值 → 去查它的赋值语句为什么没执行到。第 5 版的输出是None 32 bp—— ID 打印出None而None正是seq_id的初始值。这直接说明seq_id line[1:].split()[0]一次都没跑过于是马上找到了钥匙锁在门里。套路 2某个if永远为真 → 它被挪错了位置。第 6 版里if seq_id is not None:紧跟在seq_id ...后面必然为真。这个判断原本是要保护还没有上一条的情况 —— 它永远为真就说明它跟保护对象被拆开了。四、方法论三关分开过这 9 版让我明白代码正确不是一个判断是三个。关查什么我的实际情况功能关正常输入结果对不对第 7 版才过边界关空输入、重复输入、异常输入第 8 版才过性能关代价随数据量怎么长第 8 版全绿但慢 125 倍三个测试全绿不等于代码没问题。测试查的是结果对不对查不出代价有多大。配套的工程习惯回归测试regression test—— 大白话确认你修好新问题的时候没把老功能弄坏。我准备了三个输入文件每改一次就全跑一遍test_vntr.fa - 3 条,69/40/32 test_empty.fa - {} test_dup.fa - 打印警告,保留最后一条第 8 版就是靠这个发现修好空文件的同时把性能弄坏了。关于重复 ID 该怎么处理—— 我一开始写成ifseq_idinrecords:print(warning)else:records[seq_id].join(chunks)# 重复时就不存了这是丢数据比覆盖更危险 —— 覆盖至少条数还对丢了连痕迹都没有。正确做法是照存、额外喊一声ifseq_idinrecords:print(f警告:ID 重复{seq_id})# 多出来的一句,不是 elserecords[seq_id].join(chunks)另外print(warning)这种日志等于没写 ——出问题时它不告诉你是哪个 ID。日志要带上下文。中篇小结这四版的共同点:Python 一声不吭。版本现象报错吗第 6 版序列整体错位,标签配错货不报,assert还通过第 7 版空文件冒出{None: }鬼记录不报第 8 版三个测试全绿,慢 125 倍不报第 9 版append成了死代码,序列全空不报,条数还对能靠报错发现的问题,是最容易的那一类。真正要命的是这四种 —— 只能靠主动设计测试(空输入、重复输入、大输入)去逼它们现形。下篇会讲两件事:一是这套缓冲累积逻辑怎么照搬到 VCF / GTF / SAM二是把字典版改成yield生成器版 —— 逻辑一个字不改,内存占用从 8.4 MB 降到 200 字节,这也是 pysam 能扛住几千万条 reads 的原因。