第九章 文件及目录操作
目录
第九章 文件及目录操作
│
├── 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") |
| 读出来是什么 | str | bytes |
| 写进去是什么 | str | bytes |
| 会不会动换行符 | 会,读写都做转换 | 不会,原样搬运 |
| 典型用途 | 代码、配置、日志、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.path | pathlib |
|---|---|
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_ctime | Windows 上是创建时间 | 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.path | basename dirname split splitext exists isfile getsize abspath |
pathlib | Path 对象,.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[:] 才生效 |
高级操作
| 记住什么 | 内容 |
|---|---|
shutil | copy2 做备份、copytree 复制树、move 移动、rmtree 删树 |
tempfile | 临时文件和目录,出了 with 自动清理 |
csv | writer reader DictReader,中文配 utf-8-sig 和 newline="" |
json | dump load 读写文件,dumps loads 处理字符串,ensure_ascii=False 保中文 |
pickle | Python 对象原样存取,只信自己生成的文件 |
io | StringIO 和 BytesIO,内存里的文件替身 |
zipfile | 打包用 ZIP_DEFLATED,z.write(源, 包内名) |
| 原子写 | 写 .tmp 再 os.replace,覆盖目标别用 os.rename |
5.2 避坑清单
这十条是我自己踩过的,按被坑的次数排序。
- 相对路径依赖当前工作目录,定时任务和双击运行时工作的目录跟你以为的不一样
open(path, "w")会清空文件,静默发生,不给提示- 不写
encoding,换台机器就乱码或报错 - 忘记
close,数据还留在缓冲区,程序一崩全丢 - 用
shutil.copy做备份,修改时间被改成当前时间,增量备份失效 - 写 csv 不加
newline="",每行之间多一个空行 - 大文件用
read(),内存直接撑到文件大小 rmtree删错了目录,它没有回收站- 直接写目标文件,中断后留下半截内容,下次读它的程序全崩
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 注入这件事讲清楚。你会发现前面学的文件操作思路在这儿大部分还用得上,只是换了个地方存。