先把一条判断摆出来:能在标准库里找到的,就别装包。
理由不是「装包不好」,而是标准库没有版本冲突、没有依赖树、没有一层额外的学习成本。今天做的工具,六样东西全是自带的: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 到底算不算成功」。