第九章 文件及目录操作

15 阅读46分钟

第九章 文件及目录操作

目录

第九章  文件及目录操作
│
├── 1. 基本文件操作   → 打开与关闭 / 读写模式 / 四种读法 / 编码 / with / 文件指针
├── 2. 目录操作       → 路径 / os.path / pathlib / 创建删除 / 遍历 / 文件信息
├── 3. 高级文件操作   → shutil / tempfile / csv / json / pickle / 内存文件 / 压缩 / 大文件
├── 4. 实战           → 批量重命名 / 日志分析 / 增量备份 / 目录体检
└── 5. 小结           → 知识清单 + 避坑清单 + 5 道题 + 下一章预告

照旧建个目录第九章,本章的示例代码都放里面跑。

>>> import os
>>> os.makedirs("第九章", exist_ok=True)
>>> os.path.isdir("第九章")
True

exist_ok=True 的意思是目录已经存在也别报错。不加这个参数,第二次运行就抛 FileExistsError。写初始化代码几乎都该带上。


1. 基本文件操作

1.1 文件在程序眼里是什么

不管你存的是一首诗、一张照片还是一个可执行程序,落到硬盘上都是一串字节。区别只在于你怎么解释这些字节。

磁盘上的 6 个字节: 0xE4 0xBD 0xA0 0xE5 0xA5 0xBD

解释成 UTF-8 文本   →  "你好"
解释成 GBK 文本     →  "浣犲ソ"(乱码)
解释成 RGB 像素      →  三个字节一组,两张颜色

Python 里对应两种打开方式。

文本模式二进制模式
打开方式open(path, "r")open(path, "rb")
读出来是什么strbytes
写进去是什么strbytes
会不会动换行符会,读写都做转换不会,原样搬运
典型用途代码、配置、日志、csv图片、视频、压缩包、可执行程序

记一条就够,处理文字用文本模式,处理其他一切用二进制模式。图片没有"字符编码"这个概念,用文本模式打开一张 jpg 只会得到一坨没法用的报错。

1.2 open 与那几个模式字符

日常用的参数就俩。

f = open("chapter09/demo_open.txt", "w", encoding="utf-8")   # 路径 + 模式 + 编码
f.write("内容")
f.close()

模式字符串由几类字符拼成,第一个字符决定干什么。

字符含义文件不存在时文件已存在时
r读,默认模式抛 FileNotFoundError从头开始读
w写新建清空后写入
a追加新建在末尾接着写
x独占新建新建抛 FileExistsError

后面可以再跟一个字符修饰。

字符含义
b二进制模式
t文本模式,默认值,一般省略
+可读写,r+ w+ a+

w 这个模式得单独说一句。它清空文件是静默发生的,不给任何提示。我见过有人在循环里误写了 open(path, "w"),跑完发现文件只剩最后一条数据,前面全没了,还以为是业务逻辑错了。

>>> with open("chapter09/note.txt", "w", encoding="utf-8") as f:
...     f.write("第一次写的内容")
...
>>> with open("chapter09/note.txt", "w", encoding="utf-8") as f:
...     f.write("第二次,前面那条没了")
...
>>> open("chapter09/note.txt", encoding="utf-8").read()
'第二次,前面那条没了'

要保留原内容用 a。

>>> with open("chapter09/note.txt", "a", encoding="utf-8") as f:
...     f.write(",这是追加的")
...
>>> open("chapter09/note.txt", encoding="utf-8").read()
'第二次,前面那条没了,这是追加的'

x 模式用得少,但有个场景很合适。只想在新文件上写、绝不能覆盖已有文件时用它,比自己先 exists() 判断再写可靠。因为判断和写入之间有时间差,多进程下别人可能已经抢先建了文件,x 把这个检查交给了操作系统,一步完成。

>>> try:
...     with open("chapter09/note.txt", "x", encoding="utf-8") as f:
...         f.write("这行不会被执行")
... except FileExistsError:
...     print("文件已经在了,不覆盖")
...
文件已经在了,不覆盖

模式写错会立刻报错,不会默默生效。

>>> open("chapter09/note.txt", "rw")
ValueError: must have exactly one of create/read/write/append mode

1.3 读文件的四种方式

先造一个有内容的文本文件。后面几节都用它。

>>> poem = "静夜思\n床前明月光\n疑是地上霜\n举头望明月\n低头思故乡\n"
>>> with open("chapter09/poem.txt", "w", encoding="utf-8", newline="\n") as f:
...     f.write(poem)
...

第一种,read() 一次全读。 参数不填就是读到结尾,填了数字就是读指定个数的字符。

>>> with open("chapter09/poem.txt", encoding="utf-8") as f:
...     whole = f.read()
...
>>> whole
'静夜思\n床前明月光\n疑是地上霜\n举头望明月\n低头思故乡\n'
>>> with open("chapter09/poem.txt", encoding="utf-8") as f:
...     head = f.read(5)
...
>>> head
'静夜思\n床'

read(5) 里的 5 是字符数不是字节数。文本模式下 Python 帮你换算好了,中文一个字算一个字符,跟它占几个字节没关系。

第二种,readline() 一次读一行,包括行尾那个换行符。

>>> with open("chapter09/poem.txt", encoding="utf-8") as f:
...     f.readline()
...     second = f.readline()
...
>>> second
'床前明月光\n'

文件读完之后再 readline() 得到空字符串,这就是循环结束的判断依据。

>>> with open("chapter09/poem.txt", encoding="utf-8") as f:
...     while True:
...         line = f.readline()
...         if not line:
...             break
...         print("读到", repr(line))
...
读到 '静夜思\n'
读到 '床前明月光\n'
读到 '疑是地上霜\n'
读到 '举头望明月\n'
读到 '低头思故乡\n'

第三种,readlines() 一次读成列表。

>>> with open("chapter09/poem.txt", encoding="utf-8") as f:
...     lines = f.readlines()
...
>>> lines
['静夜思\n', '床前明月光\n', '疑是地上霜\n', '举头望明月\n', '低头思故乡\n']

第四种,直接迭代文件对象。 这个写法最省事,也是处理大文件的正确姿势。

>>> with open("chapter09/poem.txt", encoding="utf-8") as f:
...     for line in f:
...         print(repr(line))
...
'静夜思\n'
'床前明月光\n'
'疑是地上霜\n'
'举头望明月\n'
'低头思故乡\n'

怎么选,看文件大小。

                    小文件(几 MB 以内)        大文件(几百 MB 起)
                    ┌─────────────────┐      ┌─────────────────┐
read()              │  首选,一把梭     │      │  内存会爆        │
readlines()         │  要列表时方便     │      │  内存会爆        │
readline() 循环     │  可以但啰嗦       │      │  可以,一行一行   │
for line in f       │  推荐             │      │  首选            │
                    └─────────────────┘      └─────────────────┘

read() 和 readlines() 都是把整个文件装进内存。一个 2 GB 的日志文件用 read() 就真的会去申请 2 GB。for line in f 是惰性读取,一次只在内存里放一行,文件多大都不怕。

读出来的行尾带 \n,直接 print 会出现空行,用 rstrip("\n") 去掉。

>>> with open("chapter09/poem.txt", encoding="utf-8") as f:
...     for line in f:
...         print(line.rstrip("\n"))
...
静夜思
床前明月光
疑是地上霜
举头望明月
低头思故乡

1.4 写文件的两个细节

write() 的返回值是写入的字符数,多数时候用不上,调试时挺直观。

>>> with open("chapter09/count.txt", "w", encoding="utf-8") as f:
...     n1 = f.write("abc")
...     n2 = f.write("defgh")
...
>>> n1, n2
(3, 5)

writelines() 接收一个字符串列表,但不会自动加换行,这跟名字给人的直觉相反。

>>> with open("chapter09/lines.txt", "w", encoding="utf-8", newline="\n") as f:
...     f.writelines(["第一行", "第二行", "第三行"])
...
>>> open("chapter09/lines.txt", encoding="utf-8").read()
'第一行第二行第三行'
>>> with open("chapter09/lines.txt", "w", encoding="utf-8", newline="\n") as f:
...     f.writelines(["第一行\n", "第二行\n", "第三行\n"])
...
>>> open("chapter09/lines.txt", encoding="utf-8").read()
'第一行\n第二行\n第三行\n'

我自己更常这么写,拼好一个字符串再一次 write。

>>> rows = ["第一行", "第二行", "第三行"]
>>> with open("chapter09/lines.txt", "w", encoding="utf-8", newline="\n") as f:
...     f.write("\n".join(rows) + "\n")
...
>>> open("chapter09/lines.txt", encoding="utf-8").read()
'第一行\n第二行\n第三行\n'

第二个细节是换行符。文本模式下写文件,\n 会被翻译成当前系统的换行符。

>>> with open("chapter09/win.txt", "w", encoding="utf-8") as f:
...     f.write("a\nb\n")
...
>>> os.path.getsize("chapter09/win.txt")
6
>>> open("chapter09/win.txt", "rb").read()
b'a\r\nb\r\n'

我写进去 4 个字符,落到盘上是 6 个字节。Windows 上 \n 被翻译成了 \r\n,两个字符各多占 1 字节。

读的时候反过来,\r\n 会被翻译回 \n,所以你读到的字符串永远是干净的 \n。这个来回翻译叫通用换行符,好处是同一份代码在 Windows 和 Linux 上读到的结果一致。

要是你想原样读写,比如处理一份已经约定好用 \r\n 的网络协议数据,加 newline="" 关掉翻译。

>>> with open("chapter09/win.txt", "w", encoding="utf-8", newline="") as f:
...     f.write("a\nb\n")
...
>>> os.path.getsize("chapter09/win.txt")
4
>>> open("chapter09/win.txt", "rb").read()
b'a\nb\n'

写 csv 的时候必须加 newline="",3.3 节会解释原因。

1.5 编码,每次都要写 encoding

open() 不写 encoding 参数时,用的是系统默认编码。

>>> import locale
>>> locale.getpreferredencoding(False)
'utf-8'

这台机器上是 utf-8。换一台中文 Windows,很可能就是 cp936(也就是 GBK)。同一份代码在你电脑上跑得好好的,到同事那里打开就报错,八成是这里出的问题。

所以养成习惯,只要打开文本文件就把 encoding="utf-8" 写上。多打几个字符,省掉一次跨国 debug。

编码不匹配的报错长这样。

>>> with open("chapter09/gbk.txt", "w", encoding="utf-8") as f:
...     f.write("中文内容")
...
>>> try:
...     print(open("chapter09/gbk.txt", encoding="gbk").read())
... except UnicodeDecodeError as e:
...     print("UnicodeDecodeError:", e)
...
UnicodeDecodeError: 'gbk' codec can't decode byte 0xad in position 2: illegal multibyte sequence

用 errors 参数可以强行读下去,代价是内容变成乱码。抢救一份重要日志时有点用,别用在正常流程里。

>>> open("chapter09/gbk.txt", encoding="gbk", errors="replace").read()
'涓�鏂囧唴瀹�'

那个菱形问号是替换字符,表示这一个字节没能解出来。

还有个变种值得记住,Windows 的记事本和 Excel 喜欢在 UTF-8 文件开头塞三个字节 EF BB BF,叫 BOM。用 utf-8 读这种文件,第一行开头会多出一个看不见的字符。

>>> with open("chapter09/bom.txt", "w", encoding="utf-8-sig") as f:
...     f.write("name,city\n")
...
>>> open("chapter09/bom.txt", "rb").read()[:7]
b'\xef\xbb\xbfname'
>>> open("chapter09/bom.txt", encoding="utf-8").read()
'\ufeffname,city\n'
>>> open("chapter09/bom.txt", encoding="utf-8-sig").read()
'name,city\n'

要生成给 Excel 打开的 csv,用 utf-8-sig 写;要读别人从 Excel 导出的 csv,也用 utf-8-sig 读。这个坑在第五章讲编码转换时提过一次,到文件这儿又撞上了。

1.6 with 到底替我们做了什么

不用 with 的写法是这样。

f = open("chapter09/x.txt", "w", encoding="utf-8")
try:
    f.write("内容")
finally:
    f.close()

with 就是把这段语法糖化了。它保证不管中间出不出异常,close() 一定被调用。

不关文件会出两件事。第一件是数据可能没落盘。

>>> f = open("chapter09/buffer.txt", "w", encoding="utf-8")
>>> f.write("还没落盘")
4
>>> os.path.getsize("chapter09/buffer.txt")
0
>>> f.flush()
>>> os.path.getsize("chapter09/buffer.txt")
12
>>> f.close()

写进去的内容先进内存缓冲区,攒够了或者关闭时才真正写到硬盘。上面那个 0 就是证据,文件已经建了,内容还在内存里飘着。要是这时候程序崩了,数据就没了。

第二件是文件句柄泄漏。操作系统允许同时打开的文件数有限,循环里不停 open 又不 close,跑一会儿就会撞上 OSError: [Errno 24] Too many open files。在 Windows 上还有个更直观的后果,被 Python 打开的文件删不掉,会提示文件正在被另一个程序使用。

所以记住一条,打开文件的活一律交给 with,别自己记着关。人总会忘,机器不会。

>>> with open("chapter09/buffer.txt", encoding="utf-8") as f:
...     content = f.read()
...
>>> f.closed
True
>>> content
'还没落盘'

with 也能同时开两个文件,写复制逻辑时很方便。

>>> with open("chapter09/poem.txt", encoding="utf-8") as src, \
...      open("chapter09/poem_copy.txt", "w", encoding="utf-8", newline="\n") as dst:
...     dst.write(src.read())
...
>>> os.path.getsize("chapter09/poem_copy.txt")
74

1.7 文件指针,读到哪了

Python 在文件里维护一个位置标记,叫文件指针。读和写都从指针处开始,读完往后移。

>>> with open("chapter09/lines.txt", encoding="utf-8") as f:
...     print("开头", f.tell())
...     f.readline()
...     print("读一行后", f.tell())
...     f.read()
...     print("读完之后", f.tell())
...     print("再读", repr(f.read()))
...
开头 0
读一行后 10
读完之后 30
再读 ''

tell() 返回的是字节偏移,不是字符序号。第一行\n 是 3 个汉字(9 字节)加 1 个换行符,所以是 10。读完之后指针停在 30,就是整个文件的字节数。

读完再读得到空字符串,因为指针已经在末尾。要重新读,用 seek() 把指针挪回去。

>>> with open("chapter09/lines.txt", encoding="utf-8") as f:
...     f.read()
...     print("读完了,指针在", f.tell())
...     f.seek(0)
...     print("挪回开头,再读一行", repr(f.readline()))
...
读完了,指针在 30
挪回开头,再读一行 '第一行\n'

文本模式下 seek 有限制,只能跳到开头或者之前 tell() 返回过的位置。想从当前位置往前挪、或者从文件末尾倒着数,直接报错。

>>> with open("chapter09/lines.txt", encoding="utf-8") as f:
...     f.readline()
...     try:
...         f.seek(-3, 1)
...     except OSError as e:
...         print(type(e).__name__ + ":", e)
...
UnsupportedOperation: can't do nonzero cur-relative seeks

原因是文本模式要做编码转换,随便跳到某个字节位置可能正落在半个汉字中间,Python 干脆禁止了。二进制模式没这个顾虑,可以随便跳。

>>> with open("chapter09/lines.txt", "rb") as f:
...     f.seek(4)
...     chunk = f.read(3)
...
>>> chunk
b'\xb8\x80\xe8'

读出来的是半个字加另一个字的开头。二进制模式不保证你读到的是完整字符,这也解释了为什么文本模式要限制 seek。

1.8 文件操作的报错对照表

这一节的报错我按"看到了先查什么"的方式列一遍。全篇统一只贴最后一行,前面的调用栈省略。

>>> try:
...     open("chapter09/没有这个文件.txt", encoding="utf-8")
... except FileNotFoundError as e:
...     print(type(e).__name__ + ":", e)
...
FileNotFoundError: [Errno 2] No such file or directory: 'chapter09/没有这个文件.txt'
>>> try:
...     open("chapter09/nodir/x.txt", "w", encoding="utf-8")
... except FileNotFoundError as e:
...     print(type(e).__name__ + ":", e)
...
FileNotFoundError: [Errno 2] No such file or directory: 'chapter09/nodir/x.txt'

同样是 FileNotFoundError,一种是文件不在,一种是上一级目录不在。写文件时遇到这个,先去看目录建了没有。

>>> try:
...     with open("chapter09/note.txt", "x", encoding="utf-8") as f:
...         pass
... except FileExistsError as e:
...     print(type(e).__name__ + ":", e)
...
FileExistsError: [Errno 17] File exists: 'chapter09/note.txt'
>>> import io
>>> f = open("chapter09/note.txt", encoding="utf-8")
>>> try:
...     f.write("想写点东西")
... except io.UnsupportedOperation as e:
...     print(type(e).__name__ + ":", e)
...
UnsupportedOperation: not writable
>>> f.close()

用只读模式打开却想写,报的是 UnsupportedOperation,跟系统权限没关系,是你自己开的模式不对。

报错最常见的原因
FileNotFoundError路径写错了,或者父目录没建
FileExistsError用了 x 模式,或者 mkdir 建了已有的目录
IsADirectoryError把目录当文件打开了
PermissionError没权限,或者文件正被别的程序独占(Windows 常见)
UnicodeDecodeError编码参数跟文件实际编码对不上
UnsupportedOperation模式和干的事不匹配,比如只读模式去写
ValueError模式字符串拼错了,或者文件已关闭还在读写

2. 目录操作

2.1 路径,最容易出事的地方

路径分两种。

绝对路径    D:\code\project\data\users.csv     从盘符(Linux 从 /)开始,在哪运行都一样
相对路径    data\users.csv                      从"当前工作目录"开始,换个地方运行就变

当前工作目录是进程的属性,不是脚本的属性。

>>> os.getcwd()
'D:\\4.documents\\workbuddy\\technical_documentation'

它取决于你从哪个目录启动的 Python,跟脚本文件放在哪毫无关系。我开头讲的那个事故,根子就在这儿。

要拿脚本自己所在的目录,得用 __file__。

>>> __file__
NameError: name '__file__' is not defined

在交互环境里压根没有 __file__ 这个变量,只有写成 .py 文件跑的时候它才存在,值是那个脚本的路径。这也是为什么这段代码没法在交互窗口里演示,得写成文件。拿到脚本所在目录的标准写法是这两行。

# script_dir.py
import os

BASE = os.path.dirname(os.path.abspath(__file__))
print("脚本所在目录", BASE)
print("拼一个数据文件路径", os.path.join(BASE, "data", "users.csv"))
脚本所在目录 D:\4.documents\workbuddy\technical_documentation\temp
拼一个数据文件路径 D:\4.documents\workbuddy\technical_documentation\temp\data\users.csv

我现在的习惯是,脚本开头就把 BASE 定下来,后面所有路径都从它拼出去。这样不管谁从哪个目录调用,行为都一致。

2.2 os.path 那些常用函数

os.path 是个模块,名字太顺手了,很多人以为它是个对象。它里面全是处理路径字符串的函数,除了 exists 那几个,其余都不碰硬盘。

>>> import os.path
>>> p = "chapter09/poem.txt"
>>> os.path.basename(p)
'poem.txt'
>>> os.path.dirname(p)
'chapter09'
>>> os.path.split(p)
('chapter09', 'poem.txt')
>>> os.path.splitext(p)
('chapter09/poem', '.txt')

splitext 和 split 容易搞混,前者按最后一个点切,后者按最后一个分隔符切。

>>> os.path.exists(p)
True
>>> os.path.isfile(p), os.path.isdir("chapter09")
(True, True)
>>> os.path.getsize(p)
74
>>> os.path.abspath(p)
'D:\\4.documents\\workbuddy\\technical_documentation\\chapter09\\poem.txt'
>>> os.path.normpath("chapter09/sub/../deep.txt")
'chapter09\\deep.txt'

整理成一张表。

函数干什么
basename(path)取最后一段,文件名或最后一级目录名
dirname(path)去掉最后一段,剩下的部分
split(path)一次拿到 (dirname, basename)
splitext(path)拆成 (主体, 后缀),后缀带点
join(a, b, ...)拼路径,自动用当前系统的分隔符
exists(path)存在吗,不管文件还是目录
isfile(path) / isdir(path)是文件吗 / 是目录吗
getsize(path)文件大小,字节数
abspath(path)转绝对路径,不做存在性检查
normpath(path)化简 .. 和 .
relpath(path, start)算相对路径
getmtime(path)最后修改时间,时间戳

2.3 别用加号拼路径

新手最容易写出来的东西是这样。

folder = "data"
name = "users.csv"
path = folder + "\\" + name
print(path)

在 Windows 上能跑,拿到 Linux 上就变成 data\/users.csv,找不到文件。而且反斜杠在字符串里要转义,写 \\ 很别扭。

正确做法用 os.path.join。

>>> os.path.join("data", "users.csv")
'data\\users.csv'
>>> os.path.join("chapter09", "sub", "deep.txt")
'chapter09\\sub\\deep.txt'

它用当前系统的分隔符拼,Windows 上出 \,Linux 上出 /,代码不用改。

join 有个反直觉的行为要记住,后面的参数如果是绝对路径,前面拼的东西全作废。

>>> os.path.join("chapter09", "D:/other", "x.txt")
'D:/other\\x.txt'

这个设计是为了方便"给个默认目录,传进来的如果是绝对路径就以它为准"。但如果你拼的路径来自用户输入,就得小心,用户传个 /etc/passwd 进来,你的 chapter09 前缀就凭空消失了。

2.4 pathlib,另一套写法

Python 3.4 之后有了 pathlib,把路径当对象而不是字符串。两套都能用,我现在的默认选择是 pathlib,写起来短,读起来清楚。

>>> from pathlib import Path
>>> p = Path("chapter09") / "poem.txt"
>>> p
WindowsPath('chapter09/poem.txt')
>>> p.name, p.stem, p.suffix
('poem.txt', 'poem', '.txt')
>>> p.parent
WindowsPath('chapter09')
>>> str(p)
'chapter09\\poem.txt'

最舒服的是 / 运算符能拼路径,看着就像在写路径。

>>> Path("chapter09") / "sub" / "deep.txt"
WindowsPath('chapter09/sub/deep.txt')

读写文件也封装好了,不用再 open 一遍。

>>> q = Path("chapter09/hello.txt")
>>> q.write_text("你好\n第二行\n", encoding="utf-8")
7
>>> q.read_text(encoding="utf-8")
'你好\n第二行\n'

Path 对象和字符串可以互相转,老代码要字符串就用 str() 或 os.fspath()。

>>> os.path.getsize(str(q))
19
>>> os.fspath(q)
'chapter09\\hello.txt'

两套 API 的对照关系记一下,遇到别人的代码能看懂就行。

os / os.pathpathlib
os.path.join(a, b)Path(a) / b
os.path.basename(p)Path(p).name
os.path.dirname(p)Path(p).parent
os.path.splitext(p)(Path(p).stem, Path(p).suffix)
os.path.exists(p)Path(p).exists()
os.path.getsize(p)Path(p).stat().st_size
os.getcwd()Path.cwd()
os.mkdir(p)Path(p).mkdir()
open(p).read()Path(p).read_text(encoding=...)

顺带一提,Path 有两个具体实现,WindowsPath 和 PosixPath。你不用管,直接写 Path(...),它自己会挑。

2.5 建目录与删目录

建目录两个函数,区别在于能不能一次建多级。

>>> os.makedirs("chapter09/a/b/c", exist_ok=True)
>>> os.path.isdir("chapter09/a/b/c")
True
>>> try:
...     os.mkdir("chapter09/x/y")
... except FileNotFoundError as e:
...     print(type(e).__name__ + ":", e.strerror)
...
FileNotFoundError: 系统找不到指定的路径。

mkdir 只能建一级,父目录不在就报错。makedirs 会把中间缺的层级全建出来。日常几乎都用 makedirs 加 exist_ok=True。

删除反过来看。

>>> os.remove("chapter09/hello.txt")
>>> os.path.exists("chapter09/hello.txt")
False

os.remove 删文件,不能删目录。删目录用 os.rmdir,但它只能删空目录。

>>> os.makedirs("chapter09/tmpx", exist_ok=True)
>>> os.rmdir("chapter09/tmpx")
>>> os.path.exists("chapter09/tmpx")
False
>>> os.makedirs("chapter09/tmpx", exist_ok=True)
>>> with open("chapter09/tmpx/in.txt", "w", encoding="utf-8") as f:
...     f.write("有内容")
...
>>> try:
...     os.rmdir("chapter09/tmpx")
... except OSError as e:
...     print(type(e).__name__ + ":", e.strerror)
...
OSError: 目录不是空的。

要连内容一起删,用 shutil.rmtree。

>>> import shutil
>>> shutil.rmtree("chapter09/tmpx")
>>> os.path.exists("chapter09/tmpx")
False

rmtree 这个命令值得敬畏,它不进回收站,删掉就真没了。我写删除逻辑时会加两道保险。

>>> def safe_remove_tree(path, must_contain):
...     """路径里不含指定关键字就拒绝删除,防止删错地方"""
...     if must_contain not in str(path):
...         raise ValueError("路径里没有 %s,拒绝删除 %s" % (must_contain, path))
...     if not os.path.isdir(path):
...         print("本来就不存在", path)
...         return False
...     shutil.rmtree(path)
...     print("已删除", path)
...     return True
...
>>> safe_remove_tree("chapter09/tmpx", "chapter09")
本来就不存在 chapter09/tmpx
False
>>> try:
...     safe_remove_tree("/tmp/important", "chapter09")
... except ValueError as e:
...     print(type(e).__name__ + ":", e)
...
ValueError: 路径里没有 chapter09,拒绝删除 /tmp/important

第一道是路径白名单,第二道是先判断存在性。多写几行,换一个睡得着的觉。

2.6 列出目录里有什么

先造个干净的演示目录,免得跟前面攒下的文件混在一起。

>>> box = "chapter09/listbox"
>>> os.makedirs(box, exist_ok=True)
>>> for name in ["a.txt", "b.log", "sub"]:
...     target = os.path.join(box, name)
...     if name == "sub":
...         os.makedirs(target, exist_ok=True)
...     else:
...         with open(target, "w", encoding="utf-8") as f:
...             f.write("x")
...

os.listdir 返回名字列表,不带路径。

>>> os.listdir(box)
['a.txt', 'b.log', 'sub']

它不区分文件和目录,要判断得自己再 isfile 一次。想一次拿到类型,os.scandir 更省事,它还顺带缓存了文件属性,遍历大目录时比 listdir 快。

>>> with os.scandir(box) as it:
...     for entry in sorted(it, key=lambda e: e.name):
...         print(entry.name, "文件" if entry.is_file() else "目录")
...
a.txt 文件
b.log 文件
sub 目录

pathlib 里对应的是 iterdir(),返回 Path 对象,后面能直接接着拼。

>>> sorted(x.name for x in Path(box).iterdir())
['a.txt', 'b.log', 'sub']

这三个都是一层,不会往子目录里钻。要递归得用下面这节的东西。

2.7 递归遍历整个目录树

os.walk 是老牌方案,一次给你三样东西。

>>> base = "chapter09/walkdemo"
>>> os.makedirs(os.path.join(base, "src", "utils"), exist_ok=True)
>>> os.makedirs(os.path.join(base, "docs"), exist_ok=True)
>>> for rel in ["README.md", "src/main.py", "src/utils/helper.py", "docs/guide.md"]:
...     with open(os.path.join(base, rel), "w", encoding="utf-8") as f:
...         f.write("# demo\n")
...
>>> for root, dirs, files in os.walk(base):
...     print("目录", root)
...     print("  子目录", dirs)
...     print("  文件  ", files)
...
目录 chapter09/walkdemo
  子目录 ['docs', 'src']
  文件   ['README.md']
目录 chapter09/walkdemo\docs
  子目录 []
  文件   ['guide.md']
目录 chapter09/walkdemo\src
  子目录 ['utils']
  文件   ['main.py']
目录 chapter09/walkdemo\src\utils
  子目录 []
  文件   ['helper.py']

它自上而下走,每到一个目录就把当前目录、它的子目录名列表、它的文件名列表交给你。

dirs 列表有个妙用,原地改它能剪枝。比如要跳过所有 node_modules。

>>> os.makedirs(os.path.join(base, "node_modules", "pkg"), exist_ok=True)
>>> with open(os.path.join(base, "node_modules", "pkg", "index.js"), "w", encoding="utf-8") as f:
...     f.write("// js")
...
>>> for root, dirs, files in os.walk(base):
...     dirs[:] = [d for d in dirs if d != "node_modules"]
...     if "index.js" in files:
...         print("找到了", root)
...

上面一段没有任何输出,说明 node_modules 整棵子树被跳过了。注意是 dirs[:] = ... 而不是 dirs = ...,前者原地改列表,walk 下一层会读到;后者只是把局部变量指向新列表,walk 看不见,剪枝不生效。这个细节我第一次用时踩过,写了半天没效果。

只要文件路径不想管目录,glob 更短。

>>> import glob
>>> glob.glob("chapter09/walkdemo/**/*.py", recursive=True)
['chapter09/walkdemo\\src\\main.py', 'chapter09/walkdemo\\src\\utils\\helper.py']
>>> glob.glob("chapter09/walkdemo/*.md")
['chapter09/walkdemo\\README.md']

** 配 recursive=True 才表示任意层级。不加这个参数,** 只当一层用,这是个高频踩坑点。

pathlib 的版本更顺手,rglob 自带递归。

>>> sorted(str(x) for x in Path(base).rglob("*.py"))
['chapter09\\walkdemo\\src\\main.py', 'chapter09\\walkdemo\\src\\utils\\helper.py']
>>> sorted(str(x) for x in Path(base).glob("*.md"))
['chapter09\\walkdemo\\README.md']
>>> sorted(str(x) for x in Path(base).rglob("*.js"))
['chapter09\\walkdemo\\node_modules\\pkg\\index.js']

最后一行顺便说明了,rglob 不会自动跳过 node_modules。真要跳过得自己过滤。

>>> sorted(str(x) for x in Path(base).rglob("*.py") if "node_modules" not in x.parts)
['chapter09\\walkdemo\\src\\main.py', 'chapter09\\walkdemo\\src\\utils\\helper.py']

2.8 文件和目录的元信息

os.stat 返回一个对象,里面塞满了属性。

>>> st = os.stat("chapter09/poem.txt")
>>> st.st_size
74
>>> isinstance(st.st_mtime, float)
True

st_mode 那堆数字是权限位,日常几乎用不上。真正常用的是三个。

属性含义Windows 上的注意点
st_size字节数目录的 size 通常是 0,别拿它算目录大小
st_mtime最后修改时间改内容会变
st_ctimeWindows 上是创建时间Linux 上是最后改属性的时间,两边含义不同

时间戳是个浮点数,要变成人能看的样子得转换。下面用一个固定的秒数演示,你自己跑的时候换成文件的 st_mtime 就行。

>>> import datetime, time
>>> ts = 1791424686.0
>>> datetime.datetime.fromtimestamp(ts)
datetime.datetime(2026, 10, 8, 9, 58, 6)
>>> datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
'2026-10-08 09:58:06'
>>> time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
'2026-10-08 09:58:06'
>>> datetime.datetime.fromtimestamp(ts).isoformat(timespec="seconds")
'2026-10-08T09:58:06'

算"多久之前"也很常用,两个时间戳相减就行。

>>> "%.0f 小时前" % ((1791500000.0 - ts) / 3600)
'21 小时前'

嫌 stat 啰嗦的话,两个快捷函数够用。

>>> os.path.getsize("chapter09/poem.txt")
74
>>> isinstance(os.path.getmtime("chapter09/poem.txt"), float)
True

顺便说个查磁盘空间的地方,写清理脚本前先看一眼。这个输出跟你机器的硬盘有关,我这儿是 409 GB 左右。

import shutil

du = shutil.disk_usage(".")
print("总 %.1f GB,已用 %.1f GB,剩余 %.1f GB"
      % (du.total / 2 ** 30, du.used / 2 ** 30, du.free / 2 ** 30))

3. 高级文件操作

前面两节搞定的是"打开、读写、找到文件"。这一节解决的是更实际的几件事,复制粘贴目录、读写结构化数据、处理大文件、保证写入不出岔子。

3.1 shutil,复制移动删除都在这儿

shutil 是 Python 自带的"高级文件工具箱",它补上了 os 里没有的那些整块操作。

函数干什么
shutil.copyfile(src, dst)复制内容,只要文件内容
shutil.copy(src, dst)复制内容 + 权限位
shutil.copy2(src, dst)复制内容 + 权限 + 修改时间等元数据
shutil.copymode(src, dst)只复制权限位
shutil.copytree(src, dst)递归复制整棵目录树
shutil.move(src, dst)移动文件或目录,跨盘也行
shutil.rmtree(path)递归删除目录
shutil.disk_usage(path)查磁盘容量
shutil.which(cmd)在系统 PATH 里找可执行文件

先看清 copy 和 copy2 的差别,这个区别在做备份时很关键。

>>> import shutil
>>> shutil.copy("chapter09/poem.txt", "chapter09/c1.txt")
'chapter09/c1.txt'
>>> shutil.copy2("chapter09/poem.txt", "chapter09/c2.txt")
'chapter09/c2.txt'
>>> os.path.getmtime("chapter09/c1.txt") == os.path.getmtime("chapter09/poem.txt")
False
>>> os.path.getmtime("chapter09/c2.txt") == os.path.getmtime("chapter09/poem.txt")
True

copy 之后目标文件的修改时间变成了"刚刚",copy2 连这个都一起抄过来。做备份一定要用 copy2,否则全量复制完,所有文件的修改时间都变成同一时刻,下次增量备份就全乱了。

copyfile 和 copy 还有个区别,前者要求目标是一个完整的文件路径,后者允许目标是目录。

>>> os.makedirs("chapter09/boxdir", exist_ok=True)
>>> shutil.copy("chapter09/poem.txt", "chapter09/boxdir")
'chapter09/boxdir\\poem.txt'
>>> os.listdir("chapter09/boxdir")
['poem.txt']

复制整个目录树用 copytree。

>>> shutil.copytree("chapter09/boxdir", "chapter09/boxdir_bak")
'chapter09/boxdir_bak'
>>> os.listdir("chapter09/boxdir_bak")
['poem.txt']

它默认要求目标目录不存在,重复执行会报错。

>>> try:
...     shutil.copytree("chapter09/boxdir", "chapter09/boxdir_bak")
... except FileExistsError as e:
...     print(type(e).__name__ + ":", e.strerror)
...
FileExistsError: 当文件已存在时,无法创建该文件。

Python 3.8 之后加了个 dirs_exist_ok=True,允许往已有目录里合并,写增量同步的时候用得上。

>>> shutil.copytree("chapter09/boxdir", "chapter09/boxdir_bak", dirs_exist_ok=True)
'chapter09/boxdir_bak'
>>> os.listdir("chapter09/boxdir_bak")
['poem.txt']

移动用 move,文件和目录都行,跨磁盘分区也行(内部会退化成复制加删除)。

>>> shutil.move("chapter09/c1.txt", "chapter09/c1_moved.txt")
'chapter09/c1_moved.txt'
>>> os.path.exists("chapter09/c1.txt"), os.path.exists("chapter09/c1_moved.txt")
(False, True)

同一块磁盘内移动是改个目录项,瞬间完成;跨盘就得真搬数据,大文件能明显感觉到慢。

3.2 tempfile,安全地用临时文件

需要中间文件的时候别自己发明名字,用 tempfile。它会把文件放到系统临时目录,名字随机生成,不会撞车。

>>> import tempfile
>>> with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
...     f.write("临时内容")
...     name = f.name
...
>>> os.path.basename(name)[:3]
'tmp'
>>> os.path.exists(name)
True
>>> open(name, encoding="utf-8").read()
'临时内容'
>>> os.remove(name)

delete=False 是让文件在关闭后保留。默认 delete=True,出了 with 块文件就没了,适合只当临时中转的场景。

要临时目录用 TemporaryDirectory,同样是出了 with 就自动清理。

>>> with tempfile.TemporaryDirectory() as d:
...     inside = os.path.isdir(d)
...     with open(os.path.join(d, "x.txt"), "w", encoding="utf-8") as f:
...         f.write("1")
...
>>> inside
True
>>> os.path.exists(d)
False

系统临时目录在哪儿,可以查一下。

>>> tempfile.gettempdir()
'C:\\Users\\施明\\AppData\\Local\\Temp'

这个路径跟你机器的用户名有关,你自己跑出来的会不一样。

我写过的下载脚本基本都是这个套路,先下载到临时文件,下载完确认校验和没问题,再 move 到正式位置。直接往正式位置写,中途断了就留一个半截文件,比没有更麻烦。

3.3 csv,表格数据

csv 是最通用的表格交换格式,Excel、数据库、各种语言都认。csv 模块帮你处理引号和分隔符这些细节。

>>> import csv
>>> rows = [("姓名", "语文", "数学"), ("张三", 88, 92), ("李四", 95, 89)]
>>> with open("chapter09/scores.csv", "w", encoding="utf-8-sig", newline="") as f:
...     csv.writer(f).writerows(rows)
...
>>> open("chapter09/scores.csv", "rb").read()
b'\xef\xbb\xbf\xe5\xa7\x93\xe5\x90\x8d,\xe8\xaf\xad\xe6\x96\x87,\xe6\x95\xb0\xe5\xad\xa6\r\n\xe5\xbc\xa0\xe4\xb8\x89,88,92\r\n\xe6\x9d\x8e\xe5\x9b\x9b,95,89\r\n'

两个参数都不能省。encoding="utf-8-sig" 是为了让 Excel 打开时中文不乱码(1.5 节讲的 BOM),newline="" 的原因看下面。

试试去掉 newline="" 会发生什么。

>>> with open("chapter09/bad.csv", "w", encoding="utf-8-sig") as f:
...     csv.writer(f).writerows([("a", "b"), ("1", "2")])
...
>>> open("chapter09/bad.csv", "rb").read()
b'\xef\xbb\xbfa,b\r\r\n1,2\r\r\n'

注意那个 \r\r\n。csv 模块自己写了 \r\n 作为行结束(这是 csv 的标准做法),文本模式又把其中的 \n 翻译成了 \r\n,于是多出一个 \r。读回来每行之间会多一个空行。

>>> with open("chapter09/bad.csv", encoding="utf-8-sig") as f:
...     bad_rows = list(csv.reader(f))
...
>>> bad_rows
[['a', 'b'], [], ['1', '2'], []]

那些空列表就是多出来的空行。写 csv 一定要加 newline="",这个坑我踩过不止一次。

读回来注意一件事,所有值都是字符串,数字不会自动转。

>>> with open("chapter09/scores.csv", encoding="utf-8-sig", newline="") as f:
...     data = list(csv.reader(f))
...
>>> data
[['姓名', '语文', '数学'], ['张三', '88', '92'], ['李四', '95', '89']]
>>> data[1][1], type(data[1][1])
('88', <class 'str'>)

要算平均分得自己 int() 转。DictReader 能把第一行当表头,读出来是字典,用着更顺手。

>>> with open("chapter09/scores.csv", encoding="utf-8-sig", newline="") as f:
...     records = [dict(row) for row in csv.DictReader(f)]
...
>>> records[0]
{'姓名': '张三', '语文': '88', '数学': '92'}
>>> sum(int(r["语文"]) for r in records) / len(records)
91.5

3.4 json,配置和数据交换的首选

json 能表达嵌套结构,比 csv 强得多,读写就两个函数。

>>> import json
>>> cfg = {"host": "localhost", "port": 8080, "debug": True}
>>> json.dumps(cfg)
'{"host": "localhost", "port": 8080, "debug": true}'
>>> json.loads('{"a": 1}')
{'a': 1}

s 后缀表示 string,dumps 是"转成字符串",loads 是"从字符串读出来"。带 dump / load(没有 s)的版本是直接读写文件。

中文默认会被转义成 \uXXXX,照样能用,就是不好读。

>>> json.dumps({"名字": "张三"})
'{"\\u540d\\u5b57": "\\u5f20\\u4e09"}'
>>> json.dumps({"名字": "张三"}, ensure_ascii=False)
'{"名字": "张三"}'

写进文件时我一般加两个参数,ensure_ascii=False 让中文保持原样,indent=2 让它换行缩进,人眼能看。

>>> with open("chapter09/cfg.json", "w", encoding="utf-8") as f:
...     json.dump(cfg, f, ensure_ascii=False, indent=2)
...
>>> print(open("chapter09/cfg.json", encoding="utf-8").read())
{
  "host": "localhost",
  "port": 8080,
  "debug": true
}
>>> with open("chapter09/cfg.json", encoding="utf-8") as f:
...     loaded = json.load(f)
...
>>> loaded
{'host': 'localhost', 'port': 8080, 'debug': True}

json 的类型是有限的几个,True 变成小写的 true,元组会变成列表,字典的键必须是字符串。Python 特有的东西(比如 datetime、自定义类的实例)它存不了,硬存会报 TypeError。

>>> try:
...     json.dumps({"now": __import__("datetime").datetime.now()})
... except TypeError as e:
...     print(type(e).__name__ + ":", e)
...
TypeError: Object of type datetime is not JSON serializable

遇到这种情况,要么先转成字符串再存,要么用下面的 pickle。

3.5 pickle,Python 对象的快速存取

pickle 能把几乎任何 Python 对象原样存到文件里,取出来还是原来的类型。

>>> import pickle
>>> data = {"name": "张三", "scores": [88, 92], "ok": True}
>>> with open("chapter09/data.pkl", "wb") as f:
...     pickle.dump(data, f)
...
>>> with open("chapter09/data.pkl", "rb") as f:
...     restored = pickle.load(f)
...
>>> restored
{'name': '张三', 'scores': [88, 92], 'ok': True}

注意模式是 wb 和 rb,pickle 存的是二进制,没有 encoding 这一说。

它还能存 json 存不了的类型。

>>> import datetime
>>> now = datetime.datetime(2026, 10, 8, 9, 58)
>>> back = pickle.loads(pickle.dumps(now))
>>> back, type(back).__name__
(datetime.datetime(2026, 10, 8, 9, 58), 'datetime')
>>> back == now
True

取回来还是 datetime 对象,不用自己做字符串转换,这一点比 json 强。

两个必须知道的缺点。

一,pickle 的文件只有 Python 能读。 别的语言读不了,人也没法直接看。要跟别的系统交换数据,用 json。

二,pickle 的数据不能来自不可信的来源。 反序列化的时候会执行数据里描述的构造逻辑,一个精心构造的 pickle 文件能让你的程序去干任何事。网上下载的 .pkl 模型文件,只从信任的来源拿。

另外 pickle 的格式随 Python 版本演进,跨大版本可能不兼容。做长期存储别用它,顶多当缓存。

3.6 内存里的文件,StringIO 和 BytesIO

有些库只接受"文件对象",可你的数据在内存里。这时候不用真建一个临时文件,io 模块提供了内存中的文件替身。

>>> import io
>>> s = io.StringIO()
>>> s.write("第一行\n")
4
>>> s.write("第二行\n")
4
>>> s.getvalue()
'第一行\n第二行\n'
>>> s.seek(0)
0
>>> s.read()
'第一行\n第二行\n'

StringIO 是文本版,BytesIO 是二进制版,用法一样。它们跟真正的文件对象接口一致,所以能直接传给需要文件的函数。

>>> buf = io.BytesIO()
>>> buf.write(b"\x00\x01\x02")
3
>>> buf.seek(0)
0
>>> buf.read()
b'\x00\x01\x02'
>>> buf.closed
False
>>> buf.close()
>>> buf.closed
True

典型场景是,把内存里生成的内容交给一个只认文件的接口,或者反过来,把网络响应当成文件读。

3.7 zipfile,打包和解压

Python 自带压缩支持,不用装任何东西。

>>> import zipfile
>>> os.makedirs("chapter09/zpsrc", exist_ok=True)
>>> for name, body in [("a.txt", "AAA"), ("b.txt", "BBB")]:
...     with open(os.path.join("chapter09/zpsrc", name), "w", encoding="utf-8") as f:
...         f.write(body)
...
>>> with zipfile.ZipFile("chapter09/bundle.zip", "w", zipfile.ZIP_DEFLATED) as z:
...     z.write("chapter09/zpsrc/a.txt", "a.txt")
...     z.write("chapter09/zpsrc/b.txt", "b.txt")
...

z.write(源文件路径, 包内名字),第二个参数是它在压缩包里叫什么。不传的话会把完整路径一起塞进去,解压出来是一串目录。

查看包里有什么。

>>> with zipfile.ZipFile("chapter09/bundle.zip") as z:
...     names = z.namelist()
...     content = z.read("a.txt")
...     info = [(i.filename, i.file_size, i.compress_size) for i in z.infolist()]
...
>>> names
['a.txt', 'b.txt']
>>> content
b'AAA'
>>> info
[('a.txt', 3, 5), ('b.txt', 3, 5)]

有意思的是压缩后 5 字节比原始 3 字节还大。压缩算法对小文件有固定开销,内容太短反而变胖。文件大的时候才能看出效果。

不用解压就能读内容,z.read() 返回的是字节。

解压用 extractall。

>>> os.makedirs("chapter09/zipout", exist_ok=True)
>>> with zipfile.ZipFile("chapter09/bundle.zip") as z:
...     z.extractall("chapter09/zipout")
...
>>> sorted(os.listdir("chapter09/zipout"))
['a.txt', 'b.txt']
>>> open("chapter09/zipout/a.txt", encoding="utf-8").read()
'AAA'

追加文件用 "a" 模式打开 zip,tarfile 模块处理 .tar.gz 是同一套思路,接口长得差不多。

3.8 大文件,分块读写

前面说过 for line in f 适合大文本文件。碰到没有换行概念的二进制大文件,得分块读。

先造一个 100 KB 的文件。

>>> with open("chapter09/big.bin", "wb") as f:
...     f.write(bytes(range(256)) * 400)
...
>>> os.path.getsize("chapter09/big.bin")
102400

分块复制的写法是一个 while 循环,读完的空 bytes 就是结束信号。

>>> def copy_chunks(src, dst, chunk=8192):
...     total = 0
...     with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
...         while True:
...             block = fsrc.read(chunk)
...             if not block:
...                 break
...             fdst.write(block)
...             total += len(block)
...     return total
...
>>> copy_chunks("chapter09/big.bin", "chapter09/big_copy.bin")
102400
>>> os.path.getsize("chapter09/big_copy.bin")
102400

这个逻辑 shutil 已经封装好了,实际用的时候写一行就够。

>>> with open("chapter09/big.bin", "rb") as a, open("chapter09/big_copy2.bin", "wb") as b:
...     shutil.copyfileobj(a, b, 8192)
...
>>> os.path.getsize("chapter09/big_copy2.bin")
102400

分块大小 8192 是个常用值,太小了系统调用次数多,太大了内存占用高,8 KB 到 1 MB 之间都合理。

3.9 原子写,别让程序留下半截文件

这一节是我觉得最值钱的一节。

直接往目标文件写,写到一半程序崩了(断电、被杀、磁盘满),文件就停在半截状态。下次读它的程序面对一个不完整的 json,只能报错。

正确做法是先写临时文件,写完整了再一步替换。

>>> def atomic_write(path, text, encoding="utf-8"):
...     tmp = path + ".tmp"
...     with open(tmp, "w", encoding=encoding) as f:
...         f.write(text)
...         f.flush()
...         os.fsync(f.fileno())
...     os.replace(tmp, path)
...
>>> atomic_write("chapter09/config.json", '{"ok": true}')
>>> open("chapter09/config.json", encoding="utf-8").read()
'{"ok": true}'
>>> os.path.exists("chapter09/config.json.tmp")
False

三个要点。flush() 把 Python 的缓冲区推给操作系统,fsync() 要求操作系统真的写到盘上(这步可以省,但做了更保险),最后 os.replace() 是原子操作,要么完全成功要么完全没发生,不存在中间状态。

任何时刻去看 config.json,它要么是旧的完整版本,要么是新的完整版本,绝不可能是半截。

替换的时候别用 os.rename。在 Windows 上目标已存在时 rename 会直接报错。

>>> with open("chapter09/r1.txt", "w", encoding="utf-8") as f:
...     f.write("旧")
...
>>> with open("chapter09/r2.txt", "w", encoding="utf-8") as f:
...     f.write("新")
...
>>> try:
...     os.rename("chapter09/r2.txt", "chapter09/r1.txt")
... except OSError as e:
...     print(type(e).__name__ + ":", e.strerror)
...
FileExistsError: 当文件已存在时,无法创建该文件。
>>> os.replace("chapter09/r2.txt", "chapter09/r1.txt")
>>> open("chapter09/r1.txt", encoding="utf-8").read()
'新'
>>> os.path.exists("chapter09/r2.txt")
False

Linux 上 rename 会静默覆盖,Windows 上报错,两边的行为不一样。要写跨平台的代码,覆盖目标一律用 os.replace。

3.10 高级操作的几个坑

坑一,shutil.copy 当备份用。 前面说过了,它会把修改时间改成当前时间,增量备份就废了。用 copy2。

坑二,pickle 加载来路不明的文件。 反序列化不是"读数据",是"执行指令"。只加载自己生成的文件。

坑三,zip 解压的路径穿越。 一个恶意的压缩包里可能有 ../../etc/passwd 这种名字,extractall 会把它写到包外去。Python 3.12 之后 extractall 做了一些防护,但处理不可信的压缩包时还是要自己检查 namelist()。

坑四,忘记 newline="" 写 csv。 3.3 节演示过了,每行之间多一个空行。

坑五,大文件用 read()。 内存会先涨到文件大小,几个 G 的日志直接把进程撑死。


4. 实战

四个实战,从简单到复杂。每个都能直接复制下来跑,它们会自己造测试数据,不依赖前面的章节。

4.1 实战一,批量重命名

需求,一个目录里有一堆照片,文件名乱七八糟。要按 前缀_三位序号.后缀 的规则重命名,只处理指定后缀,并且在真正改名之前先打印一遍计划,确认无误再动手。

这个"先干跑"的习惯是我被坑出来的。早年间我直接写重命名,跑完发现序号顺序跟预期不一样,文件名已经改了,只能一个个手动改回来。

"""实战一 批量重命名工具

规则:把 data 目录下指定后缀的文件,按 前缀_三位序号.后缀 重命名
先干跑一遍看看会发生什么,确认了再真改
"""

import os
import shutil
import unicodedata

WORK = os.path.join("chapter09", "photos")


def pad(text, width):
    """按实际显示宽度补空格,中文算两列"""
    shown = 0
    for ch in str(text):
        shown += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
    return str(text) + " " * max(0, width - shown)


def make_demo_files():
    """造点测试用的文件,先清掉上一轮跑出来的"""
    shutil.rmtree(WORK, ignore_errors=True)
    os.makedirs(WORK, exist_ok=True)
    names = ["IMG_20260101.jpg", "IMG_20260102.jpg", "截图 2026-01-03.png",
             "hello.txt", "IMG_20260104.jpg", "头像 .png"]
    for name in names:
        with open(os.path.join(WORK, name), "w", encoding="utf-8") as f:
            f.write("demo")
    return names


def list_files(folder, suffixes):
    """列出符合条件的普通文件,按名字排序"""
    result = []
    for name in sorted(os.listdir(folder)):
        path = os.path.join(folder, name)
        if not os.path.isfile(path):
            continue
        _, ext = os.path.splitext(name)
        if ext.lower() in suffixes:
            result.append(name)
    return result


def plan_rename(folder, suffixes, prefix, start=1):
    """生成重命名计划 [(旧名, 新名), ...],跳过会撞车的"""
    plan = []
    used = set(list_files(folder, suffixes))
    index = start
    for old in list_files(folder, suffixes):
        _, ext = os.path.splitext(old)
        while True:
            new = "%s_%03d%s" % (prefix, index, ext)
            index += 1
            if new not in used or new == old:
                break
        used.discard(old)
        used.add(new)
        plan.append((old, new))
    return plan


def show_plan(plan):
    print(pad("原文件名", 22) + pad("->", 4) + "新文件名")
    print("-" * 48)
    for old, new in plan:
        print(pad(old, 22) + pad("->", 4) + new)


def execute(plan, folder):
    done = 0
    for old, new in plan:
        if old == new:
            continue
        os.rename(os.path.join(folder, old), os.path.join(folder, new))
        done += 1
    return done


def main():
    make_demo_files()
    print("目录内容:", sorted(os.listdir(WORK)))
    print()
    plan = plan_rename(WORK, {".jpg"}, "旅行")
    print("只处理 jpg,干跑一遍:")
    show_plan(plan)
    print()
    print("现在真的改:")
    count = execute(plan, WORK)
    print("改了 %d 个,目录现在是 %s" % (count, sorted(os.listdir(WORK))))


main()

运行结果。

目录内容: ['IMG_20260101.jpg', 'IMG_20260102.jpg', 'IMG_20260104.jpg', 'hello.txt', '头像 .png', '截图 2026-01-03.png']

只处理 jpg,干跑一遍:
原文件名              ->  新文件名
------------------------------------------------
IMG_20260101.jpg      ->  旅行_001.jpg
IMG_20260102.jpg      ->  旅行_002.jpg
IMG_20260104.jpg      ->  旅行_003.jpg

现在真的改:
改了 3 个,目录现在是 ['hello.txt', '头像 .png', '截图 2026-01-03.png', '旅行_001.jpg', '旅行_002.jpg', '旅行_003.jpg']

几个值得说的点。

list_files 里先 sorted(os.listdir(...)),保证序号按文件名的字典序分配。不排序的话顺序取决于文件系统,同一个目录在不同机器上可能排出不同结果。

plan_rename 里维护了一个 used 集合,新名字如果撞上还没被改的旧名字,就跳到下一个序号。这个检查看着多余,实际很必要,目录里如果已经有一个 旅行_001.jpg,不检查就会在改的过程中把它覆盖掉。

pad 函数按显示宽度补空格,中文字符占两列。这个是第六章实战一里写过的,处理命令行表格时一直用得上。

还有个不起眼但关键的地方。make_demo_files 开头那句 shutil.rmtree(WORK, ignore_errors=True),先把上一轮跑出来的东西清掉。不加这句,你跑第二遍的时候目录里已经有 旅行_001.jpg 了,序号会从 004 开始,跟着教程对不上。写演示脚本时让它可重复运行,读者照着跑才不会懵。

4.2 实战二,日志分析器

需求,读一份应用日志,统计各级别的条数、按小时看分布、把错误明细挑出来,最后写一份文本报告。

"""实战二 日志分析器

读一份应用日志,统计各级别条数、错误出现的时间段、最常见的报错
"""

import os
import re
import unicodedata
from collections import Counter

WORK = os.path.join("chapter09", "logs")
LOG = os.path.join(WORK, "app.log")
REPORT = os.path.join(WORK, "report.txt")

# 一行长这样:2026-01-05 08:12:33 [ERROR] 订单 10086 支付失败
PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2}):(\d{2}) \[(\w+)\] (.+)$")


def pad(text, width):
    """按实际显示宽度补空格,中文算两列"""
    shown = 0
    for ch in str(text):
        shown += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
    return str(text) + " " * max(0, width - shown)


def make_demo_log():
    """造一份测试日志"""
    os.makedirs(WORK, exist_ok=True)
    lines = [
        "2026-01-05 08:12:33 [INFO] 服务启动,监听 8080",
        "2026-01-05 08:15:02 [INFO] 用户 admin 登录",
        "2026-01-05 09:03:41 [WARNING] 连接池使用率 85%",
        "2026-01-05 09:44:10 [ERROR] 订单 10086 支付失败",
        "2026-01-05 10:02:55 [INFO] 定时任务开始",
        "2026-01-05 10:03:12 [ERROR] 订单 10087 支付失败",
        "2026-01-05 11:20:07 [WARNING] 磁盘剩余空间不足",
        "2026-01-05 13:31:44 [ERROR] 数据库连接超时",
        "2026-01-05 14:05:19 [INFO] 用户 admin 退出",
        "2026-01-05 15:47:02 [ERROR] 订单 10090 支付失败",
        "2026-01-05 16:02:38 [CRITICAL] 主进程异常退出",
    ]
    with open(LOG, "w", encoding="utf-8") as f:
        f.write("\n".join(lines) + "\n")
    return len(lines)


def parse(path):
    """解析日志,返回 [(日期, 小时, 级别, 内容), ...]"""
    records = []
    broken = 0
    with open(path, encoding="utf-8") as f:
        for line in f:
            line = line.rstrip("\n")
            match = PATTERN.match(line)
            if not match:
                broken += 1
                continue
            date, hour, _, _, level, message = match.groups()
            records.append((date, hour, level, message))
    return records, broken


def main():
    total = make_demo_log()
    records, broken = parse(LOG)
    print("日志 %s,共 %d 行,解析成功 %d 行" % (LOG, total, len(records)))
    if broken:
        print("  有 %d 行没认出来,格式对不上" % broken)
    print()

    levels = Counter(level for _, _, level, _ in records)
    print(pad("级别", 12) + pad("条数", 8) + "占比")
    print("-" * 34)
    for level, count in sorted(levels.items(), key=lambda kv: -kv[1]):
        print(pad(level, 12) + pad(count, 8) + "%.0f%%" % (count * 100 / len(records)))
    print()

    hours = Counter(hour for _, hour, _, _ in records)
    print("按小时分布:", dict(sorted(hours.items())))
    print()

    errors = [m for _, _, level, m in records if level in ("ERROR", "CRITICAL")]
    print("错误细节,共 %d 条:" % len(errors))
    for message in errors:
        print("  " + message)
    print()

    keywords = Counter()
    for message in errors:
        for word in re.findall(r"[\u4e00-\u9fa5]{2,}", message):
            keywords[word] += 1
    print("错误里出现最多的词:", keywords.most_common(4))

    with open(REPORT, "w", encoding="utf-8") as f:
        f.write("日志分析报告\n")
        f.write("源文件  %s\n" % LOG)
        f.write("总行数  %d\n" % total)
        f.write("解析成功 %d,失败 %d\n" % (len(records), broken))
        f.write("\n各级别条数\n")
        for level, count in sorted(levels.items(), key=lambda kv: -kv[1]):
            f.write("  %-10s %d\n" % (level, count))
        f.write("\n错误明细\n")
        for message in errors:
            f.write("  " + message + "\n")
    print()
    print("报告已写到 %s,%d 字节" % (REPORT, os.path.getsize(REPORT)))


main()

运行结果。

日志 chapter09\logs\app.log,共 11 行,解析成功 11 行

级别        条数    占比
----------------------------------
INFO        4       36%
ERROR       4       36%
WARNING     2       18%
CRITICAL    1       9%

按小时分布: {'08': 2, '09': 2, '10': 2, '11': 1, '13': 1, '14': 1, '15': 1, '16': 1}

错误细节,共 5 条:
  订单 10086 支付失败
  订单 10087 支付失败
  数据库连接超时
  订单 10090 支付失败
  主进程异常退出

错误里出现最多的词: [('订单', 3), ('支付失败', 3), ('数据库连接超时', 1), ('主进程异常退出', 1)]

报告已写到 chapter09\logs\report.txt,334 字节

这里最值得学的是 parse 函数的写法。逐行读而不是一次 read(),日志动辄几个 G,一次性读进来内存吃不消。而且 for line in f 的写法天然支持"边读边处理",将来要处理实时追加的日志也只用改一点点。

正则 ^(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2}):(\d{2}) \[(\w+)\] (.+)$ 把一行拆成日期、时、分、秒、级别、内容六个组。\[ 和 \] 要转义,因为方括号在正则里是字符类。第五章讲过这个。

broken 计数是个好习惯。真实日志里总有些行对不上格式,可能是多行堆栈、可能是别的组件写的。统计出来心里有数,别让它们悄悄消失。

4.3 实战三,增量备份

需求,把一个目录同步到备份目录。只复制新增和改动过的文件,源里已经删掉的文件在备份里也要删掉,并且每一步都要能看见。

"""实战三 增量备份

把一个目录同步到备份目录:只复制新增和改动过的文件,删掉源里已经没有的
"""

import os
import shutil
import unicodedata


def pad(text, width):
    """按实际显示宽度补空格,中文算两列"""
    shown = 0
    for ch in str(text):
        shown += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
    return str(text) + " " * max(0, width - shown)


def scan(folder):
    """扫描目录,返回 {相对路径: (大小, 修改时间)}"""
    found = {}
    for root, _, files in os.walk(folder):
        for name in files:
            path = os.path.join(root, name)
            rel = os.path.relpath(path, folder)
            info = os.stat(path)
            found[rel] = (info.st_size, round(info.st_mtime, 2))
    return found


def make_demo_dirs(src, dst):
    """造源目录和一份旧备份,先清掉上一轮跑出来的"""
    shutil.rmtree(src, ignore_errors=True)
    shutil.rmtree(dst, ignore_errors=True)
    os.makedirs(os.path.join(src, "子目录"), exist_ok=True)
    os.makedirs(dst, exist_ok=True)
    files = {
        "readme.txt": "说明文档",
        "main.py": "print('hi')",
        "子目录/helper.py": "def help(): pass",
        "笔记.md": "# 笔记",
    }
    for rel, content in files.items():
        with open(os.path.join(src, rel), "w", encoding="utf-8") as f:
            f.write(content)
    # 备份里放一份旧的 readme,和一个源里已经删掉的文件
    with open(os.path.join(dst, "readme.txt"), "w", encoding="utf-8") as f:
        f.write("旧内容")
    with open(os.path.join(dst, "废弃.txt"), "w", encoding="utf-8") as f:
        f.write("早就删了")


def diff(src_map, dst_map):
    """算出要新增、更新、删除哪些"""
    add = [k for k in src_map if k not in dst_map]
    update = [k for k in src_map
              if k in dst_map and src_map[k][0] != dst_map[k][0]]
    remove = [k for k in dst_map if k not in src_map]
    same = [k for k in src_map if k in dst_map and src_map[k][0] == dst_map[k][0]]
    return add, update, remove, same


def sync(src, dst, remove_extra=True):
    src_map = scan(src)
    dst_map = scan(dst)
    add, update, remove, same = diff(src_map, dst_map)

    print(pad("动作", 8) + pad("文件", 26) + "说明")
    print("-" * 52)
    for rel in add:
        print(pad("新增", 8) + pad(rel, 26) + "%d 字节" % src_map[rel][0])
    for rel in update:
        print(pad("更新", 8) + pad(rel, 26) +
              "%d -> %d 字节" % (dst_map[rel][0], src_map[rel][0]))
    for rel in same:
        print(pad("跳过", 8) + pad(rel, 26) + "内容一样")
    for rel in remove:
        print(pad("删除", 8) + pad(rel, 26) + "源里已经没有")

    copied = 0
    for rel in add + update:
        target = os.path.join(dst, rel)
        os.makedirs(os.path.dirname(target), exist_ok=True)
        shutil.copy2(os.path.join(src, rel), target)
        copied += 1

    removed = 0
    if remove_extra:
        for rel in remove:
            os.remove(os.path.join(dst, rel))
            removed += 1

    print()
    print("复制 %d 个,删除 %d 个" % (copied, removed))
    return copied, removed


def main():
    src = os.path.join("chapter09", "docs")
    dst = os.path.join("chapter09", "backup")
    make_demo_dirs(src, dst)
    print("源目录:", sorted(scan(src)))
    print("备份目录:", sorted(scan(dst)))
    print()
    sync(src, dst)
    print()
    print("同步后再扫一遍源和备份,应该一模一样")
    print(sorted(scan(src)) == sorted(scan(dst)), sorted(scan(dst)))


main()

运行结果。

源目录: ['main.py', 'readme.txt', '子目录\\helper.py', '笔记.md']
备份目录: ['readme.txt', '废弃.txt']

动作    文件                      说明
----------------------------------------------------
新增    main.py                   11 字节
新增    笔记.md                   8 字节
新增    子目录\helper.py          16 字节
更新    readme.txt                9 -> 12 字节
删除    废弃.txt                  源里已经没有

复制 4 个,删除 1 个

同步后再扫一遍源和备份,应该一模一样
True ['main.py', 'readme.txt', '子目录\\helper.py', '笔记.md']

scan 用相对路径当键,这是让两边能对比的前提。绝对路径在源和备份里不可能一样,只有相对路径可比。

判断改动用的是文件大小。更严谨的做法是比对内容的哈希,读一遍文件算 md5 或者 sha256。大文件多的时候算哈希很慢,用大小加修改时间是个务实的折中。真要做可靠的备份,用 filecmp 模块的 cmp 函数,它先比大小再比内容,效率不错。

复制用的是 copy2 而不是 copy,这个在 3.1 节讲过。目标文件的修改时间必须跟源一致,否则下次同步会把所有文件都判成"改动过",增量备份退化成全量。

sync 里的 remove_extra 参数我建议保留。删文件是最危险的动作,先跑一遍带 False 的版本看看会删什么,确认了再开。

4.4 实战四,目录体检

需求,扫一个目录,按后缀统计数量和体积,把空文件和最大的几个文件揪出来,再导出一份 csv 报告。

"""实战四 目录体检

扫一个目录,按后缀统计数量和体积,揪出空文件和大文件
"""

import csv
import os
import unicodedata
from collections import defaultdict


def pad(text, width):
    """按实际显示宽度补空格,中文算两列"""
    shown = 0
    for ch in str(text):
        shown += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
    return str(text) + " " * max(0, width - shown)


def human(size):
    """把字节数变成人能看的形式"""
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024 or unit == "GB":
            if unit == "B":
                return "%d %s" % (size, unit)
            return "%.1f %s" % (size, unit)
        size /= 1024


def make_demo_tree(root):
    """造一棵测试用的目录树"""
    specs = {
        "README.md": "# 项目说明\n" * 5,
        "src/main.py": "print('hi')\n" * 20,
        "src/utils.py": "pass\n",
        "src/empty.py": "",
        "docs/guide.md": "# 指南\n" * 30,
        "data/sample.csv": "a,b\n1,2\n" * 50,
        "data/big.log": "x" * 3000,
        "data/none.txt": "",
    }
    for rel, content in specs.items():
        path = os.path.join(root, rel)
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)


def walk_files(root):
    """遍历出所有文件,返回 [(相对路径, 大小), ...]"""
    result = []
    for current, _, files in os.walk(root):
        for name in files:
            path = os.path.join(current, name)
            result.append((os.path.relpath(path, root), os.path.getsize(path)))
    return result


def stat_by_ext(files):
    """按后缀聚合"""
    groups = defaultdict(lambda: [0, 0])
    for rel, size in files:
        _, ext = os.path.splitext(rel)
        key = ext.lower() or "(无后缀)"
        groups[key][0] += 1
        groups[key][1] += size
    return groups


def main():
    root = os.path.join("chapter09", "project")
    make_demo_tree(root)
    files = walk_files(root)
    total = sum(size for _, size in files)

    print("扫描 %s,共 %d 个文件,合计 %s" % (root, len(files), human(total)))
    print()
    print(pad("后缀", 12) + pad("个数", 8) + pad("总体积", 12) + "平均")
    print("-" * 44)
    groups = stat_by_ext(files)
    for ext, (count, size) in sorted(groups.items(), key=lambda kv: -kv[1][1]):
        print(pad(ext, 12) + pad(count, 8) + pad(human(size), 12) + human(size // count))
    print()

    empty = [rel for rel, size in files if size == 0]
    print("空文件 %d 个:%s" % (len(empty), empty))
    big = sorted(files, key=lambda kv: -kv[1])[:3]
    print("体积前三:")
    for rel, size in big:
        print("  " + pad(rel, 20) + human(size))
    print()

    report = os.path.join("chapter09", "scan_report.csv")
    os.makedirs("chapter09", exist_ok=True)
    with open(report, "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["后缀", "个数", "总字节数"])
        for ext, (count, size) in sorted(groups.items(), key=lambda kv: -kv[1][1]):
            writer.writerow([ext, count, size])
    print("报告写到 %s" % report)
    print(open(report, encoding="utf-8-sig").read())


main()

运行结果。

扫描 chapter09\project,共 8 个文件,合计 4.0 KB

后缀        个数    总体积      平均
--------------------------------------------
.log        1       2.9 KB      2.9 KB
.csv        1       500 B       500 B
.md         2       380 B       190 B
.py         3       266 B       88 B
.txt        1       0 B         0 B

空文件 2 个:['data\\none.txt', 'src\\empty.py']
体积前三:
  data\big.log        2.9 KB
  data\sample.csv     500 B
  docs\guide.md       300 B

报告写到 chapter09\scan_report.csv
后缀,个数,总字节数
.log,1,3000
.csv,1,500
.md,2,380
.py,3,266
.txt,1,0

human 函数把字节换成 KB MB,这种函数在写任何跟容量有关的东西时都用得上。注意它是 1024 进制,硬盘厂商用的是 1000 进制,所以你买的 1 TB 硬盘在系统里只有 931 GB,这不是偷工减料,是两边的"G"定义不一样。

os.path.splitext 拿不到后缀时返回空字符串,用 ext.lower() or "(无后缀)" 兜住,免得 README 这类文件被算进空字符串那一组。

导出 csv 那两行,utf-8-sig 加 newline="",就是 3.3 节强调的组合。


5. 小结

5.1 这一章学过的东西

基本文件操作

记住什么内容
三步走打开、读写、关闭,关闭交给 with
模式字符r 读 / w 清空写 / a 追加 / x 独占新建,b 二进制,+ 读写
四种读法read() 全读、read(n) 读 n 个字符、readline() 一行、readlines() 成列表
大文件用 for line in f,一次只在内存放一行
编码每次都写 encoding="utf-8",给 Excel 的用 utf-8-sig
换行符文本模式自动翻译 \n,写 csv 要加 newline=""
文件指针tell() 看位置,seek(0) 回开头

目录操作

记住什么内容
路径相对路径看当前工作目录,脚本相关的路径用 __file__ 拼
拼路径用 os.path.join 或 Path / ,别用加号
os.pathbasename dirname split splitext exists isfile getsize abspath
pathlibPath 对象,.name .parent .stem .suffix read_text write_text
建与删makedirs(exist_ok=True) 建,remove 删文件,rmdir 删空目录,rmtree 删整棵树
遍历os.walk 递归、glob 带 recursive=True、Path.rglob
剪枝os.walk 里改 dirs[:] 才生效

高级操作

记住什么内容
shutilcopy2 做备份、copytree 复制树、move 移动、rmtree 删树
tempfile临时文件和目录,出了 with 自动清理
csvwriter reader DictReader,中文配 utf-8-sig 和 newline=""
jsondump load 读写文件,dumps loads 处理字符串,ensure_ascii=False 保中文
picklePython 对象原样存取,只信自己生成的文件
ioStringIO 和 BytesIO,内存里的文件替身
zipfile打包用 ZIP_DEFLATED,z.write(源, 包内名)
原子写写 .tmp 再 os.replace,覆盖目标别用 os.rename

5.2 避坑清单

这十条是我自己踩过的,按被坑的次数排序。

  1. 相对路径依赖当前工作目录,定时任务和双击运行时工作的目录跟你以为的不一样
  2. open(path, "w") 会清空文件,静默发生,不给提示
  3. 不写 encoding,换台机器就乱码或报错
  4. 忘记 close,数据还留在缓冲区,程序一崩全丢
  5. 用 shutil.copy 做备份,修改时间被改成当前时间,增量备份失效
  6. 写 csv 不加 newline="",每行之间多一个空行
  7. 大文件用 read(),内存直接撑到文件大小
  8. rmtree 删错了目录,它没有回收站
  9. 直接写目标文件,中断后留下半截内容,下次读它的程序全崩
  10. pickle.load 加载来路不明的文件,等于执行别人给的代码

5.3 五道练习题

先把环境准备好。

>>> import os, csv, json, shutil
>>> os.makedirs("chapter09/ex/sub", exist_ok=True)
>>> with open("chapter09/ex/a.txt", "w", encoding="utf-8", newline="\n") as f:
...     f.write("第一行\n第二行比较长\n第三行\n")
...
>>> with open("chapter09/ex/sub/b.txt", "w", encoding="utf-8") as f:
...     f.write("1234567890")
...
>>> open("chapter09/ex/empty.txt", "w").close()
>>> sorted(os.listdir("chapter09/ex"))
['a.txt', 'empty.txt', 'sub']

第一题,统计 a.txt 的行数、字符总数、最长那一行的长度。注意字符数算不算行尾的换行符,两种答案都要。

>>> with open("chapter09/ex/a.txt", encoding="utf-8") as f:
...     lines = f.readlines()
...
>>> len(lines)
3
>>> sum(len(line) for line in lines)
15
>>> sum(len(line.rstrip("\n")) for line in lines)
12
>>> max(len(line) for line in lines)
7

含换行符是 15,不含是 12,差 3 就是三个换行符。最长行 7,是"第二行比较长"六个字加换行符。

第二题,递归算出 chapter09/ex 目录的总大小。提示,目录自己的 st_size 是 0,别被它骗了。

>>> os.path.getsize("chapter09/ex")
0
>>> def dir_size(path):
...     total = 0
...     for root, dirs, files in os.walk(path):
...         for name in files:
...             total += os.path.getsize(os.path.join(root, name))
...     return total
...
>>> dir_size("chapter09/ex")
49

第三题,找出 chapter09/ex 下所有空文件。

>>> empties = []
>>> for root, dirs, files in os.walk("chapter09/ex"):
...     for name in files:
...         full = os.path.join(root, name)
...         if os.path.getsize(full) == 0:
...             empties.append(full)
...
>>> empties
['chapter09/ex\\empty.txt']

第四题,把一份 csv 转成 json 列表。先造数据,再转换,写出去。

>>> rows = [("姓名", "年龄"), ("张三", "18"), ("李四", "20")]
>>> with open("chapter09/ex/p.csv", "w", encoding="utf-8-sig", newline="") as f:
...     csv.writer(f).writerows(rows)
...
>>> with open("chapter09/ex/p.csv", encoding="utf-8-sig", newline="") as f:
...     data = [dict(row) for row in csv.DictReader(f)]
...
>>> data
[{'姓名': '张三', '年龄': '18'}, {'姓名': '李四', '年龄': '20'}]
>>> with open("chapter09/ex/p.json", "w", encoding="utf-8") as f:
...     json.dump(data, f, ensure_ascii=False)
...
>>> open("chapter09/ex/p.json", encoding="utf-8").read()
'[{"姓名": "张三", "年龄": "18"}, {"姓名": "李四", "年龄": "20"}]'

年龄是字符串 "18",csv 不带类型信息。要当数字用得自己 int() 转。

第五题,下面这段代码想做备份,有两个问题,找出来并改对。

>>> src = "chapter09/ex/a.txt"
>>> shutil.copy(src, "chapter09/ex/bak1.txt")
'chapter09/ex/bak1.txt'
>>> shutil.copy2(src, "chapter09/ex/bak2.txt")
'chapter09/ex/bak2.txt'
>>> os.path.getmtime(src) == os.path.getmtime("chapter09/ex/bak1.txt")
False
>>> os.path.getmtime(src) == os.path.getmtime("chapter09/ex/bak2.txt")
True

问题一在 copy,它不保留修改时间,全量备份一次之后所有文件的 mtime 都变成同一时刻,增量备份就失效了。改成 copy2。问题二是路径用了相对路径,跟 2.1 节那个事故一模一样,应该先基于 __file__ 拼出绝对路径再干活。

5.4 下一章

这一章把数据放进了文件,可读可写,也知道了怎么保证写入安全。可文件有个绕不过去的问题,数据量一大,查询就慢,而且多人同时改一份文件必然出问题。

下一章讲用 Python 操作数据库。连接数据库、建表、增删改查、事务,顺便把 SQL 注入这件事讲清楚。你会发现前面学的文件操作思路在这儿大部分还用得上,只是换了个地方存。