Python常用标准库:做一个测试结果分析工具

先把一条判断摆出来:能在标准库里找到的,就别装包。

理由不是「装包不好」,而是标准库没有版本冲突、没有依赖树、没有一层额外的学习成本。今天做的工具,六样东西全是自带的:json、csv、pathlib、argparse、logging、collections。

顺便说三个本机实测撞出来的坑,都不是抄来的经验。

一、with 不是可选的写法习惯

python 复制代码
with target.open("w", encoding="utf-8") as stream:
    json.dump(payments, stream, ensure_ascii=False, indent=2)

with 保证用完自动关闭------哪怕中间抛异常,文件也会被关。

用 open() 加手写 close(),一旦某条路径上漏掉,数据可能只写了一半,而半截 JSON 是最难查的一类 bug:能读一半、报一个语法错误、看不出是哪行写的。

写完立刻读回来核对,这是唯一可靠的验证方式:

python 复制代码
again = json.loads(target.read_text(encoding="utf-8"))
print(again == payments)          # True
print(isinstance(again, list))   # True

第二条比第一条更重要------确认读回来的还是列表,而不是变成了字典 。Day19 踩过一次,len(document) 数的是外层字段,不是订单数。

二、CSV 交给模块,因为规则比想象中麻烦

手拼在数据简单时能跑:

python 复制代码
manual = "name,note\nA,键盘\n"

问题出在值里有逗号:

text 复制代码
name,note
A,"键盘,鼠标"

这一行几列?CSV 没有类型,只认分隔符。值里有逗号要用双引号包住,双引号本身还要写成两个:

text 复制代码
B,"他说""你好"""

手工处理这套转义,漏一次就出一份错数据。而:

python 复制代码
writer = csv.DictWriter(stream, fieldnames=["name", "note"])
writer.writeheader()
writer.writerows(rows)

读写两边的规则都替你想好了。实跑确认读回来的结构和 JSON 完全一致:back == payments 为 True。

newline="" 不能省

Windows 上默认把 \n 写成 \r\n,csv 模块读的时候对不上就会多出空行。

读写两边都写 newline=""。 这不是风格问题,是 csv 模块文档明确要求的。

三、路径:读之前先检查,后缀记得 .lower()

python 复制代码
p = Path("data/payments.json")
p.name          # payments.json
p.parent.name   # data
p.suffix        # .json
p.stem          # payments
p.exists()      # True

拼接用 / ,别自己加斜杠。folder / "x.json" 在三个系统上都对,str(folder) + "\\" + "x.json" 只在 Windows 上对。

一个在 Windows 上测不出来的坑

分派文件类型时写的是:

python 复制代码
suffix = path.suffix.lower()

那个 .lower() 为什么不能省?

因为 Windows 的文件名不区分大小写。 本机实测 Path("PAYMENTS.JSON").exists() 返回 True------它和 payments.json 就是同一个文件 。而 Path.suffix 原样保留大写,返回 .JSON。

在 Linux 上这两个是两个不同的文件。不写 .lower(),同一份代码在两个系统上行为不同,而你在 Windows 上永远测不出来。

这个问题是我跑断言时发现的:一开始以为某条断言写错了,查下去才发现是操作系统帮了倒忙。

教训:跨平台代码里的任何大小写比较,都要先想一遍「这个测试环境能不能发现它」。 本机测过不等于没问题。

四、Counter:省下的不只是三行

python 复制代码
counts = {}
for p in payments:
    counts[p["status"]] = counts.get(p["status"], 0) + 1

c = Counter(p["status"] for p in payments)

四行变一行,但真正的收益在别处:

python 复制代码
c["REFUNDED"]          # 0  ------ 没出现过的键直接给 0
c.most_common(2)       # [('SUCCESS', 3), ('FAILED', 2)]
dict(c1 + c2)          # 两个 Counter 相加,当分桶用

第一条最实用:手写字典时 counts["REFUNDED"] 会抛 KeyError,必须写 .get(..., 0) 兜着。用 Counter 就没有这个分支了------少一个分支,就少一个能写错的地方。

五、argparse:type=int 是白拿的检查

python 复制代码
parser.add_argument("--limit", type=int, default=2)

有人传 --limit abc,实跑输出:

text 复制代码
usage: report [-h] [--status STATUS] [--limit LIMIT] source
report: error: argument --limit: invalid int value: 'abc'

退出码 2。这不是我手写的判断,是 argparse 自己拦的,还顺手打印了完整用法。

漏掉必填参数也是退出码 2,和「后缀不认识」(退出码 1)区分开:

退出码 含义
0 正常完成
1 工具启动了,但没完成(文件不存在、格式不认识)
2 命令行就用错了(类型错、缺参数)

这个区分对写脚本很关键:CI 里看到 2,知道是人用错了;看到 1,知道是数据或环境的问题。全部返回 0 或全部抛异常,这两种信息都丢了。

六、日志:两个坑,第二个尤其阴

print 给人看,logging 留痕迹。加 FileHandler 存文件时格式要带时间戳,否则没法按时间排查:

python 复制代码
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))

坑一:日志文件开着的时候删不掉

text 复制代码
PermissionError: 另一个程序正在使用此文件

FileHandler 一直占着它。

坑二:边遍历边删,后面的被跳过

我第一次写关处理器是这样:

python 复制代码
for handler in logger.handlers:     # 错
    handler.close()
    logger.removeHandler(handler)

结果文件还是删不掉。 removeHandler 在循环里修改了正在遍历的列表,后面的元素被跳过------FileHandler 根本没被关掉。

正确写法:

python 复制代码
for handler in list(logger.handlers):   # 对
    handler.flush()
    handler.close()
    logger.removeHandler(handler)

改完实跑确认删除成功。

凡是「遍历一个列表、同时要改这个列表」,先 list() 复制一份。

这个错误的迷惑性在于:症状是「我明明关掉了,怎么还不行」,很容易怀疑是操作系统的问题、是不是文件权限、是不是有别的进程------实际上是遍历逻辑。

七、读回结构:验证要验两层

python 复制代码
again = json.loads(target.read_text(encoding="utf-8"))
print(again == payments)          # True
print(isinstance(again, list))   # True

第二条比第一条更重要。

again == payments 为 True 只保证「内容对」------但如果 payments 本来就是个列表,那它也保证了类型对。问题是 Day19 踩过一次坑:orders.json 的顶层是 {"orders": [...]},len(document) 数到的是外层有几个字段,不是几笔订单。

数值相等和结构相等是两件事。 写回读回的验证应该同时验两层,否则你会漏掉「写成了字典的列表但按字典读」这类问题。

顺带一条实用经验:转换之后立刻验原样,不要先做点别的。 比如 read_rows 里:

python 复制代码
return document["payments"] if isinstance(document, dict) else document

这个分支是因为 JSON 顶层可能是 {"payments": [...]} 也可能直接是 [...]。如果哪天上游改了结构,这里不会报错,只会返回空列表------所以这一行必须配一个「读到 0 条就记 ERROR」的检查,而不是让它静悄悄地传下去。

八、拼起来的工具

read_rows 有三个设计点,每个都是为了把决定权留给上层:

python 复制代码
def read_rows(path):
    if not path.exists():
        logger.error("文件不存在:%s", path)
        return []
    suffix = path.suffix.lower()
    if suffix == ".json":
        ...
    if suffix == ".csv":
        ...
    logger.error("不认识的后缀:%s", path.suffix)
    return []

一、先检查再读 ,而不是让 FileNotFoundError 冒出去------对使用者来说,一句 文件不存在:xxx 比一串堆栈有用得多。

二、按后缀分派,这是最自然的依据。

三、返回空列表而不是直接退出。 「读到 0 条」是正常情况还是错误?工具不决定,main 决定:

python 复制代码
if not rows:
    logger.error("没有读到数据,退出")
    return 1

把「发现问题」和「决定怎么办」分开 ,同一个 read_rows 才能被复用到别的工具里。

输出的交叉核对

text 复制代码
条数 5、SUCCESS 3、FAILED 2、金额合计 659.3、通过率 0.6

659.3 和 Day22 里「不过滤状态」的数字一致。 通过率 0.6 的分母是成功加失败,口径从 Day21 保持到今天没变过------这比任何单元测试都更能说明代码没走样。

最后

能在标准库里找到的,就别装包。

今天没有学新语法,学的是「工具箱里已经有什么」,以及三个具体到能复现的坑:csv 的 newline="" 不能省 、后缀比较前要 .lower() 且这个坑 Windows 上测不出来 、遍历列表同时改它要先 list()。

下一篇是 Day24《Python第三方库实战:requests、Pydantic与接口数据校验》 ------今天 read_rows 只按后缀分派、不管字段缺不缺 ,下期接一个本地 HTTP 服务,用 requests 真正发请求,再用 Pydantic 回答「状态码 200 到底算不算成功」。

相关推荐
陈随易1 小时前
副业搞钱之代办永不过时
前端·后端·程序员
挖掘狂人1 小时前
用 Python 从零搭一套 RAG 系统:40 行跑通,再谈优化
后端·程序员·ai编程
codigger2 小时前
用 Python 从零搭一套 RAG 系统:40 行跑通,再谈优化
程序员·编程·ai编程·rag·#agent
SimonKing2 小时前
OpenCode免费模型直接给`Workbuddy`用,一键导入
java·后端·程序员
kyriewen19 小时前
我让 AI 当候选人面了一轮:居然一个问题都没问倒它
人工智能·程序员·ai编程
flash俊杰19 小时前
三个真实线上 Bug 复盘:pdfjs worker、Dialog 层级与中文 PDF 换行
程序员
陈随易1 天前
搞钱点子之生态鱼缸
前端·后端·程序员
codigger1 天前
AI学会写代码之后,程序员节为什么更重要了
ai·程序员·ai编程·#人工智能
挖掘狂人1 天前
1024程序员节:AI时代,为什么我们依然需要程序员
人工智能·程序员·ai编程