文章
合集Python 标准库第 1 / 8 篇

Python 标准库 Day 1:文件与路径处理

模块 1 / 5:os

1. 问题引入

你接到一个任务:写一个部署脚本,要做这些事:

  • 读环境变量 DATABASE_URL 拿到数据库地址
  • 切换到项目根目录
  • 检查 logs/ 文件夹存不存在,不存在就创建
  • 删除一周前的旧日志文件
  • 打印当前进程 ID 写入 pid.txt,方便运维管理

这个脚本里没有「网络」「计算」,全是「跟操作系统打交道」的事。这就是 os 模块的领域。

2. 模块定位

os 的全名是 operating system。它是 Python 标准库里最古老、最庞大的模块之一,包含上百个函数。

新人最大的误解是:以为 **os** 主要是处理路径的。其实路径只是它的一小部分(在子模块 os.path 里)。os 真正的角色是「操作系统接口的总入口」。

它的功能可以分成 7 大类:

类别典型函数重要性
进程与环境getcwd
, chdir
, environ
, getpid
高频
文件系统操作mkdir
, rmdir
, remove
, rename
高频
目录遍历listdir
, scandir
, walk
高频
路径处理(子模块)os.path.*高频(单独成节)
跨平台常量os.sep
, os.name
, os.linesep
中频
文件描述符 I/Oos.open
, os.read
, os.write
低频
进程控制与权限os.system
, os.fork
, os.chmod
低频

下面按这个分类逐一讲。路径处理放到下一轮(模块 2 os.path)。

3. 进程与环境

3.1 当前工作目录(cwd)

import os

print(os.getcwd())        # 获取:'/home/alice/projects'
os.chdir("/tmp")          # 切换
print(os.getcwd())        # '/tmp'

什么是 cwd? 当你写 open("data.txt") 用相对路径时,Python 去哪里找?答案就是 cwd。

为什么这是 bug 重灾区?

  • 在 PyCharm/VSCode 点「运行」时,cwd 通常是项目根目录
  • 在终端里 cd ~/Desktop 然后 python script.py,cwd 就是 ~/Desktop
  • 同一段代码在不同地方跑,结果不一样

怎么避免? 工程实践是:脚本一开头就定位到「自己所在目录」:

import os
os.chdir(os.path.dirname(os.path.abspath(__file__)))
# __file__ 是当前脚本路径,这一行让 cwd 永远等于脚本所在目录

(pathlib 的等价写法 Day 1 后面会讲,更优雅。)

3.2 环境变量 os.environ

环境变量是操作系统级别的「全局键值对」,所有进程都能读。

import os

# —— 读 ——
home = os.environ["HOME"]              # 找不到抛 KeyError
home = os.environ.get("HOME", "")      # 找不到返回默认值(推荐)
home = os.getenv("HOME", "")           # 等价于上一行的简写

# —— 写(仅影响当前进程及其子进程)——
os.environ["MY_API_KEY"] = "abc123"

# —— 删 ——
del os.environ["MY_API_KEY"]

# —— 遍历 ——
for k, v in os.environ.items():
    print(f"{k}={v}")

为什么重要?

真实项目里,密钥、数据库地址、调试开关绝不写在代码里(写进去 push 到 GitHub 就泄露了)。标准做法:

DEBUG = os.getenv("DEBUG", "false").lower() == "true"
DATABASE_URL = os.environ["DATABASE_URL"]   # 必须存在,不存在直接报错
API_KEY = os.getenv("API_KEY")              # 可选

常见误区 1:环境变量的值永远是字符串。

os.environ["COUNT"] = "10"
n = os.environ["COUNT"]
n + 1                  # 错误: TypeError: 字符串不能加整数
int(n) + 1             # 正确: 11

常见误区 2:os.environ["X"] = "1"不持久化。Python 退出后就没了。要永久设置环境变量,得改 shell 配置(Linux/Mac 的 ~/.bashrc、~/.zshrc,或 Windows 的「系统属性 → 环境变量」)。

补充说明:生产项目通常用 .env 文件配合 python-dotenv 库(第三方)来管理环境变量。但理解 os.environ 是基础——python-dotenv 本质就是「读 .env 文件,把内容塞进 os.environ」。

3.3 进程信息

import os

os.getpid()         # 当前进程 ID(PID)
os.getppid()        # 父进程 ID
os.cpu_count()      # CPU 核心数(Day 6 并发时会用)
os.getlogin()       # 当前登录用户名(在某些环境下不可用)

何时用?

  • getpid():写入 pid.txt 方便运维 kill 进程;日志里加 PID 方便区分多进程输出
  • cpu_count():决定线程池/进程池大小(Day 6)

4. 文件系统操作

这些函数操作单个文件或目录。

4.1 目录创建/删除

import os

os.mkdir("logs")              # 创建单层目录,父目录不存在会报错
os.makedirs("a/b/c")          # 递归创建多层(类似 Linux mkdir -p)
os.makedirs("a/b/c", exist_ok=True)  # 已存在也不报错(推荐)

os.rmdir("logs")              # 只能删空目录,非空报错
os.removedirs("a/b/c")        # 递归删除空目录链(很少用)

关键区别:

  • mkdir vs makedirs:单层 vs 多层
  • rmdir 删不了非空目录——要删整个目录树得用 shutil.rmtree(模块 4 会讲)

4.2 文件删除/重命名

import os

os.remove("old.txt")          # 删除文件
os.unlink("old.txt")          # 等价于 remove(Unix 习惯叫法)

os.rename("a.txt", "b.txt")   # 重命名/移动
os.replace("a.txt", "b.txt")  # 强制覆盖版(如果目标已存在,rename 在 Windows 报错,replace 不会)

误区:os.rename 跨磁盘会失败(操作系统限制)。跨磁盘移动文件得用 shutil.move(模块 4)。

4.3 元信息

import os

stat = os.stat("file.txt")
print(stat.st_size)       # 字节数
print(stat.st_mtime)      # 最后修改时间(Unix 时间戳)
print(stat.st_mode)       # 权限位

os.path.exists("file.txt")  # 这些判断函数在 os.path 里(下一模块讲)

5. 目录遍历

5.1 os.listdir(path) —— 列出目录内容(最简单)

import os

names = os.listdir("/tmp")
# ['file1.txt', 'subdir', 'file2.log']
# 注意:只返回名字,不是完整路径!

陷阱:返回的是名字而不是完整路径。新人常这样写:

# 错误:直接 open 会去 cwd 找,找不到
for name in os.listdir("/tmp"):
    open(name)

# 正确:手动拼路径
for name in os.listdir("/tmp"):
    full = os.path.join("/tmp", name)
    open(full)

5.2 os.scandir(path) —— 性能更好的列目录

import os

with os.scandir("/tmp") as it:
    for entry in it:
        print(entry.name, entry.is_file(), entry.is_dir(), entry.stat().st_size)

为什么有 scandir?listdir 只给名字,要获取每个文件的大小/类型还得对每个文件再调 os.stat,很慢。scandir 一次性把元信息也取回来,遍历大目录快得多(10 倍量级)。

scandir 返回 DirEntry 对象,常用方法:

  • .name —— 文件名
  • .path —— 完整路径
  • .is_file() / .is_dir() —— 类型判断
  • .stat() —— 元信息

scandir 是 Python 3.5 引入的,PEP 471。列目录要性能就用它。

5.3 os.walk(path) —— 递归遍历整棵目录树

这是 os 里的重型武器,至今广泛使用。

import os

for dirpath, dirnames, filenames in os.walk("/tmp/myproject"):
    print(f"当前目录: {dirpath}")
    print(f"  子目录: {dirnames}")
    print(f"  文件:   {filenames}")

每次循环给你一个三元组:

  • dirpath:当前正在遍历的目录路径(字符串)
  • dirnames:该目录下的子目录名列表(不含完整路径)
  • filenames:该目录下的文件名列表(不含完整路径)

实际用法:找出某目录下所有 .py 文件:

import os

py_files = []
for dirpath, dirnames, filenames in os.walk("/home/alice/projects"):
    for name in filenames:
        if name.endswith(".py"):
            py_files.append(os.path.join(dirpath, name))

进阶技巧:dirnames 是个可变列表,你修改它会影响遍历!这意味着可以「剪枝」:

for dirpath, dirnames, filenames in os.walk("."):
    # 跳过隐藏目录和 node_modules
    dirnames[:] = [d for d in dirnames if not d.startswith(".") and d != "node_modules"]
    # ↑ 注意是 dirnames[:] = ... 原地修改,不能写 dirnames = ...
    ...

补充说明:os.walk 默认自顶向下遍历(先父后子),可以传 topdown=False 改为自底向上。删除目录树时常用 topdown=False,因为得先删干净子目录才能删父目录。

6. 跨平台常量

import os

os.sep         # 路径分隔符:Windows '\\',Unix '/'
os.linesep     # 行尾:Windows '\r\n',Unix '\n'
os.pathsep     # PATH 分隔符:Windows ';',Unix ':'
os.name        # 'posix'(Mac/Linux)/ 'nt'(Windows)
os.devnull     # '/dev/null' 或 'nul',丢弃输出用

何时用?

  • 写跨平台脚本时偶尔用 os.name 判断系统
  • os.devnull 用来「丢弃」子进程输出(Day 6 讲 subprocess 时会用)
  • os.linesep少用——文本模式下 Python 的 open() 会自动处理换行符

7. 低频功能(了解即可)

文件描述符级 I/O

fd = os.open("file.txt", os.O_RDONLY)
data = os.read(fd, 100)
os.close(fd)

这是 Unix 系统调用的直接封装,比 open() 低一层。日常用内置 open() 就够了,只有写底层库或对接 C 接口时才需要。

进程控制

os.system("ls -l")            # 执行 shell 命令(不推荐,有安全和编码问题)
os.fork()                     # 创建子进程,仅 Unix
os.execv(...)                 # 替换当前进程

os.system 已过时,新代码用 subprocess 模块(Day 6 会讲)。fork 一般通过 multiprocessing 模块间接使用。

权限相关

os.chmod("file.txt", 0o644)   # 改权限(八进制)
os.chown("file.txt", uid, gid)  # 改所有者(仅 Unix)

写运维脚本或处理 Linux 部署时用,日常开发少见。

8. 代码实践

用 os 模块做开头那个「部署脚本」的简化版:

import os
import time

# 1. 读环境变量
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///default.db")
print(f"使用数据库: {DATABASE_URL}")

# 2. 切换到脚本所在目录
script_dir = os.path.dirname(os.path.abspath(__file__))
# __file__ 是当前 .py 文件路径
# 但如果你在交互模式跑这段,没有 __file__,可以用 os.getcwd() 代替
# script_dir = os.getcwd()
os.chdir(script_dir)
print(f"工作目录已切换到: {os.getcwd()}")

# 3. 确保 logs 目录存在
os.makedirs("logs", exist_ok=True)
print("logs 目录已就绪")

# 4. 删除 7 天前的旧日志
now = time.time()
seven_days = 7 * 24 * 3600

with os.scandir("logs") as it:
    for entry in it:
        if entry.is_file() and entry.name.endswith(".log"):
            age = now - entry.stat().st_mtime
            if age > seven_days:
                os.remove(entry.path)
                print(f"删除旧日志: {entry.name}")

# 5. 写入 PID
with open("pid.txt", "w") as f:
    f.write(str(os.getpid()))
print(f"PID {os.getpid()} 已写入 pid.txt")

讲解每一步:

  1. os.getenv 安全地读环境变量,提供默认值
  2. os.path.abspath(__file__) 把脚本路径转绝对路径,再用 os.path.dirname 拿到所在目录,最后 os.chdir 切过去——这样无论你从哪儿运行脚本,cwd 都正确
  3. os.makedirs(..., exist_ok=True) 是创建目录的标准写法
  4. os.scandir 高效列出文件,用 entry.stat().st_mtime 拿最后修改时间,和 time.time()(当前 Unix 时间戳)相减得到「文件多久没改了」
  5. os.getpid() 拿到 PID 写入文件

9. 练习题

理论题

  1. os.mkdir、os.makedirs、os.makedirs(..., exist_ok=True) 三者有什么区别?分别什么时候用?
回答

os.mkdir:建一个目录在本目录下,重名的话报错;

os.makedirs:建一个目录在本目录下也行,也可以跨目录,但是重名的话报错

os.makedirs(..., exist_ok=True):重名不报错

2. `os.listdir` 和 `os.scandir` 的核心区别是什么?为什么后者性能更好?
回答

os.listdir 返回一个列表:返回名字的 str,不是完整路径

scandir 返回 DirEntry 对象,常用方法:

  • .name —— 文件名
  • .path —— 完整路径
  • .is_file() / .is_dir() —— 类型判断
  • .stat() —— 元信息

listdir 只给名字,要获取每个文件的大小/类型还得对每个文件再调 os.stat,很慢。scandir 一次性把元信息也取回来,遍历大目录快得多(10 倍量级)。

3. `os.environ["X"] = "value"` 这种修改在 Python 进程退出后还存在吗?为什么?
回答

不存在,新建的环境变量只对当前的进程和其子进程有效,程序退出后就无效了

4. `os.walk` 返回的三元组里,`dirnames` 是可变列表有什么用途?
回答

原地修改这个列表,可以同步做一些筛选和剪枝的工作

5. 为什么 `os.system("ls")` 不推荐使用?
回答

1、有安全和编码问题 ,可能是有安全风险,比如代码注入啥的

2、无法跨平台,得对每套平台都写一遍

3、只返回退出码,无法捕获输出

#### 代码实操题 写一个脚本 `clean_pyc.py`:递归扫描指定目录,删除所有 `__pycache__` 目录和 `.pyc` 文件(Python 编译缓存,git 仓库里经常误提交)。

要求:

  • 用 os.walk
  • 删除前先打印将要删除的内容
  • 加一个 DRY_RUN = True 开关,True 时只打印不真删

提示:

  • .pyc 是文件,用 os.remove
  • __pycache__ 是目录(且非空),需要先删里面所有文件再 os.rmdir,或者直接用 shutil.rmtree(下下个模块讲,但你可以先用)
  • 修改 dirnames 来跳过 .git 目录避免误伤
  • 用 topdown=False 自底向上遍历,更适合删除场景
回答

递归清理 pyc 文件的实现

一个最简单的实现

import os
import shutil

TARGET_DIR = "test"
DRY_RUN = True

pyc_files = []
pyc_dirs = []

for dirpath, dirnames, filenames in os.walk(TARGET_DIR):
    # 剪枝:跳过常见的"不该碰"的目录
    dirnames[:] = [d for d in dirnames if d not in (".git", "node_modules", ".venv")]
    
    if "__pycache__" in dirnames:
        pyc_dirs.append(os.path.join(dirpath, "__pycache__"))
    
    for name in filenames:
        if name.endswith(".pyc"):
            pyc_files.append(os.path.join(dirpath, name))

# 打印总览
print(f"扫描完成:找到 {len(pyc_files)} 个 .pyc 文件,{len(pyc_dirs)} 个 __pycache__ 目录")
print(f"模式:{'DRY RUN(仅预览)' if DRY_RUN else '实际删除'}\n")

# 处理目录(用 shutil.rmtree 一次搞定)
for d in pyc_dirs:
    if DRY_RUN:
        print(f"[DRY RUN] 将删除目录: {d}")
    else:
        print(f"删除目录: {d}")
        shutil.rmtree(d)

# 处理散落在 __pycache__ 之外的 .pyc 文件
# (注意:__pycache__ 里的 .pyc 会随目录一起被删,这里只处理孤立的)
for f in pyc_files:
    # 跳过那些已经在 pyc_dirs 里的
    if any(f.startswith(d + os.sep) for d in pyc_dirs):
        continue
    if DRY_RUN:
        print(f"[DRY RUN] 将删除文件: {f}")
    else:
        print(f"删除文件: {f}")
        os.remove(f)

print("\n完成。")
#### 思考题 环境变量在大型项目里常用来传递配置,但有几个工程问题需要思考:
  • 怎么管理「这个项目需要哪些环境变量」?让新成员一眼就知道?
回答

我的想法是写在一个.env.example 文件里,把所有要用到的环境变量分门别类的全部写清楚

.env.example 文件的变量分类示例

+ 怎么区分「开发环境」「测试环境」「生产环境」的不同变量?
回答

我的想法: 这方面的问题不在于怎么区分,而在于怎么加载,我们可以很简单的把环境变量按不同的场景设置,但是我现在暂不清楚怎么去设置,是通过维护一个带 type(dev|test|product)的数据结构还是其他的办法,具体又怎么根据环境的不同来注入环境变量,这方面我不清楚,但是这是十分常见的情景,所以应该是有做的足够优秀的工程案例

按运行环境加载配置的示例一

按运行环境加载配置的示例二

+ 如果一个变量没设置就让程序崩溃,这是好事还是坏事?为什么有的人主张「快速失败」(fail fast)?
回答

我的想法: 我觉得这是必要的,从源头截断错误的传播,有利于防止雪崩效应,能够确保用户在程序成功启动时所有的环境都是正确且成功的,我也更倾向 fail fast,但与此同时要做好很好的 error guide 指引用户完美的搭建好自己的环境

+ 为什么 12-Factor App 这个著名的工程原则强调「配置都通过环境变量」?
回答

集中管理的思想无论是在复杂项目还是小型项目中都是值得被提倡的

十二要素应用的环境变量配置原则

10. 模块小结

**os**** 模块的核心定位**:Python 与操作系统的总接口,远不止处理路径。

重点掌握(高频):

  • os.getcwd() / os.chdir() —— 当前工作目录
  • os.environ / os.getenv() —— 环境变量
  • os.makedirs(path, exist_ok=True) —— 创建多层目录
  • os.remove() / os.rename() —— 删除/重命名
  • os.scandir() —— 高效列目录
  • os.walk() —— 递归遍历目录树
  • os.getpid() / os.cpu_count() —— 进程/系统信息

了解即可(低频或有更好替代):

  • 跨平台常量 os.sep、os.linesep
  • os.system —— 用 subprocess 替代
  • os.open/read/write —— 用内置 open() 替代
  • fork/exec/chmod —— 系统编程时再学

和下一模块的衔接: 你注意到这一节里出现了好几次 os.path.join、os.path.dirname、os.path.abspath——这些都是 os.path 子模块里的函数,专门处理「路径字符串」。下一轮我们就把 os.path 完整讲一遍。

模块 2 / 5:os.path

1. 模块定位

os.path 是 os 的子模块,专门处理路径字符串。注意三个关键词:

  • 子模块:你写 import os 之后就能用 os.path.xxx,不需要单独 import
  • 路径:只管路径本身的拼接、拆分、判断
  • 字符串:它的输入和输出都是字符串。这是它和 pathlib 最本质的区别——pathlib 用 Path 对象

它的能力可以分成 4 类:

类别例子pathlib 等价
拼接与拆分join
, split
, basename
, dirname
/
, .name
, .parent
路径形态转换abspath
, normpath
, expanduser
.resolve()
, Path.home()
判断与查询exists
, isfile
, isdir
, getsize
.exists()
, .is_file()
, .stat()
文件名解析splitext.suffix
, .stem

2. 老代码里你一定会遇到的函数

按出现频率排序:

os.path.join(*paths) —— 拼接路径 (高频)

os.path.join("/home/alice", "data", "file.txt")
# Linux: '/home/alice/data/file.txt'
# Windows: '/home/alice\\data\\file.txt'  自动用对应分隔符

关键陷阱:如果某个参数是绝对路径,前面的会被丢弃。

os.path.join("/home/alice", "/etc", "passwd")
# 结果:'/etc/passwd'  ← /home/alice 没了!

pathlib 的 / 也有这个行为,是 Unix 的传统语义。

os.path.dirname(path) 和 os.path.basename(path) —— 拆路径 (高频)

os.path.dirname("/home/alice/file.txt")    # '/home/alice'  父目录
os.path.basename("/home/alice/file.txt")   # 'file.txt'     文件名

经典组合(你在第一节看过):

os.path.dirname(os.path.abspath(__file__))
# 「当前脚本所在目录」的标准写法,老代码里到处是这一行

os.path.abspath(path) —— 转绝对路径 (高频)

os.path.abspath("data.txt")
# '/home/alice/projects/data.txt'  自动加上 cwd

把相对路径补全为绝对路径。pathlib 等价:Path("data.txt").resolve()。

os.path.exists(path) / isfile / isdir —— 判断 (高频)

os.path.exists("file.txt")    # 文件或目录存在
os.path.isfile("file.txt")    # 是文件
os.path.isdir("data")         # 是目录

陷阱:exists 对坏的符号链接返回 False。如果你想知道「路径节点是否存在」(不管链接是否有效),用 os.path.lexists。

os.path.splitext(path) —— 拆扩展名 (中频)

os.path.splitext("/a/b/file.tar.gz")
# ('/a/b/file.tar', '.gz')  ← 注意只拆最后一个点
os.path.splitext("file.txt")
# ('file', '.txt')

陷阱:拿到的扩展名带点。判断时记得是 ext == ".pdf" 不是 ext == "pdf"。

os.path.getsize(path) —— 文件大小 (中频)

os.path.getsize("file.txt")   # 字节数

等价于 os.stat(path).st_size,只是更直接。

os.path.expanduser(path) —— 展开 ~ (中频)

os.path.expanduser("~/Downloads")
# '/home/alice/Downloads'

~ 在 shell 里表示用户主目录,但 Python 不会自动展开,需要这个函数。

os.path.normpath(path) —— 规范化 (低频)

os.path.normpath("a/b/../c/./d")
# 'a/c/d'

把 ..、.、重复斜杠这些都消除。abspath 内部会调用它。

3. 完整的「老代码 vs 新代码」对照表

把这张表收藏起来,看老代码时直接翻译:

老代码(os.path)新代码(pathlib)
os.path.join(a, b, c)Path(a) / b / c
os.path.dirname(p)Path(p).parent
os.path.basename(p)Path(p).name
os.path.splitext(p)[0]Path(p).stem
os.path.splitext(p)[1]Path(p).suffix
os.path.abspath(p)Path(p).resolve()
os.path.expanduser("~")Path.home()
os.path.exists(p)Path(p).exists()
os.path.isfile(p)Path(p).is_file()
os.path.isdir(p)Path(p).is_dir()
os.path.getsize(p)Path(p).stat().st_size
os.path.dirname(os.path.abspath(__file__))Path(__file__).resolve().parent

4. 一段典型老代码

import os

# 项目结构定位
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
LOG_DIR = os.path.join(BASE_DIR, "logs")

# 检查并创建
if not os.path.exists(DATA_DIR):
    os.makedirs(DATA_DIR)

# 遍历文件
for filename in os.listdir(DATA_DIR):
    full_path = os.path.join(DATA_DIR, filename)
    if os.path.isfile(full_path) and filename.endswith(".csv"):
        size = os.path.getsize(full_path)
        print(f"{filename}: {size} 字节")

翻译成 **pathlib** 版本(下个模块会详细讲):

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
LOG_DIR = BASE_DIR / "logs"

DATA_DIR.mkdir(exist_ok=True)

for f in DATA_DIR.iterdir():
    if f.is_file() and f.suffix == ".csv":
        print(f"{f.name}: {f.stat().st_size} 字节")

行数少了,可读性也高。

下面这段老代码做了什么?请你心里默念一遍每一行:

import os

def backup_file(path):
    if not os.path.isfile(path):
        return None
    
    dir_name = os.path.dirname(path)
    base = os.path.basename(path)
    name, ext = os.path.splitext(base)
    
    backup_name = name + ".backup" + ext
    backup_path = os.path.join(dir_name, backup_name)
    
    return backup_path

调用 backup_file("/home/alice/report.pdf") 会返回什么?

答案(自己想完再展开)

返回 '/home/alice/report.backup.pdf'。

逻辑:拆出目录 /home/alice,文件名 report.pdf,再拆成 report + .pdf,组装成 report.backup.pdf,最后拼回原目录。

pathlib 一行写法:p.with_stem(p.stem + ".backup")

5. 模块小结

os.path 你只需要做到一件事:**看到老代码能秒翻译成 ****pathlib**。

必须记住的 5 个函数:

  • os.path.join —— 拼路径
  • os.path.dirname / basename —— 拆路径
  • os.path.abspath —— 转绝对路径
  • os.path.exists / isfile / isdir —— 判断
  • os.path.splitext —— 拆扩展名

经典咒语:

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

这一行你 100% 会在老代码里看到,要能立刻反应过来它在干什么。

模块 3 / 5:pathlib

1. 问题引入

你接手了一个数据处理项目,需要做这些事:

  • 项目根目录下有个 data/raw/ 文件夹,里面塞着几百个 .csv 文件
  • 你要把每个 csv 处理后保存到 data/processed/,文件名加上日期后缀
  • 处理过程中,每个文件如果失败要写日志到 logs/2026-04-26/error_文件名.log
  • 同时要兼容 Windows 同事的开发环境

用 os.path 你能写出来,但代码里 os.path.join 会出现几十次,每次拼路径都得 os.path.join(os.path.dirname(...), ...),又长又脏。

pathlib 就是为了解决这个问题设计的。看完本节你能写出这样的代码:

project_root = Path(__file__).resolve().parent
raw_dir = project_root / "data" / "raw"
processed_dir = project_root / "data" / "processed"

for csv in raw_dir.glob("*.csv"):
    output = processed_dir / f"{csv.stem}_2026-04-26.csv"
    output.write_text(process(csv.read_text()))

干净、可读、跨平台。

2. 模块定位

pathlib 是 Python 3.4 引入的(PEP 428),现在是处理路径的官方推荐方式。它的核心思想是:

路径不是字符串,路径是对象。

字符串只能 +、.split、.replace,对路径来说语义不清;对象可以挂上几十个路径相关的方法,每个都有清晰的语义。

pathlib 模块里最重要的就是一个类:Path。

from pathlib import Path

剩下还有几个类(PurePath、PosixPath、WindowsPath),但**99% 的场景你只需要 ****Path**。后面会简单解释这个类层次。

Path 的能力可以分成 7 大类:

类别典型方法重要性
创建与表示Path(...)
, Path.home()
, Path.cwd()
高频
路径拼接与拆分/
, .parent
, .name
, .stem
, .suffix
, .parts
高频
路径形态转换.resolve()
, .absolute()
, .expanduser()
, .with_name()
, .with_suffix()
高频
判断与查询.exists()
, .is_file()
, .is_dir()
, .stat()
高频
文件系统操作.mkdir()
, .rename()
, .unlink()
, .touch()
高频
内容读写.read_text()
, .write_text()
, .read_bytes()
, .open()
高频
目录遍历.iterdir()
, .glob()
, .rglob()
高频

下面按这个分类逐一讲。

3. 创建与表示

3.1 创建 Path 对象

from pathlib import Path

# 方式 1:从字符串创建
p = Path("/home/USERNAME/data.txt")
p = Path("data/file.txt")            # 相对路径也行
p = Path(r"C:\Users\USERNAME\data.txt") # Windows 路径

# 方式 2:从多个部分创建(等价于 join)
p = Path("home", "USERNAME", "data.txt")
# Linux 上得到 PosixPath('home/alice/data.txt')

# 方式 3:从已有 Path 创建
p2 = Path(p, "subdir")  # 在 p 后面继续拼

# 方式 4:特殊起点
Path.home()    # 用户主目录
Path.cwd()     # 当前工作目录

打印一下看看:

>>> Path("/home/USERNAME/data.txt")
PosixPath('/home/alice/data.txt')        # Linux/Mac 上
WindowsPath('C:/Users/alice/data.txt')   # Windows 上

补充说明:Path 是「平台感知」的——同一行代码 Path("a/b") 在 Linux 上是 PosixPath,在 Windows 上是 WindowsPath,操作行为也会自动适配该平台。这是它跨平台的关键。

3.2 Path 对象的"字符串视图"

p = Path("/home/USERNAME/data.txt")

str(p)        # '/home/alice/data.txt'  转字符串
p.as_posix()  # '/home/alice/data.txt'  强制用 / 分隔(Windows 上有用)
p.as_uri()    # 'file:///home/alice/data.txt'  转 URI 格式

何时用 **str(p)**? 调用一些只接受字符串的老 API 时。Python 3.6+ 后大部分标准库(包括 open()、os.*)都接受 Path 对象,但有些第三方库还要求字符串。

4. 路径拼接与拆分

4.1 用 / 拼接(核心特性)

p = Path("/home/alice")
sub = p / "data" / "file.txt"
# PosixPath('/home/alice/data/file.txt')

/ 运算符被重载成「路径拼接」。这是 pathlib 最优雅的设计。

关键陷阱(和 os.path.join 一样):

Path("/home/alice") / "/etc" / "passwd"
# PosixPath('/etc/passwd')  ← /home/alice 被丢了

如果右侧是绝对路径,左侧被丢弃。这是 Unix 路径的传统语义。

4.2 拆分路径的常用属性

p = Path("/home/alice/data/report.tar.gz")

p.name      # 'report.tar.gz'        最后一段
p.stem      # 'report.tar'           最后一段去掉最后一个扩展名
p.suffix    # '.gz'                  最后一个扩展名
p.suffixes  # ['.tar', '.gz']        所有扩展名
p.parent    # PosixPath('/home/alice/data')   父目录
p.parents   # 所有祖先(懒加载)
p.parts     # ('/', 'home', 'alice', 'data', 'report.tar.gz')
p.anchor    # '/'                    根(Windows 是 'C:\\')
p.root      # '/'                    根

**重点解释 ****.parents**:

list(p.parents)
# [PosixPath('/home/alice/data'),
#  PosixPath('/home/alice'),
#  PosixPath('/home'),
#  PosixPath('/')]

p.parents[0]    # 直接父目录(等价 p.parent)
p.parents[1]    # 父目录的父目录
p.parents[-1]   # 最顶层

注意 **.suffix** 的"只看最后一个点"行为:

Path("file.tar.gz").suffix     # '.gz'   ← 只是 .gz!
Path("file.tar.gz").suffixes   # ['.tar', '.gz']
Path("file.tar.gz").stem       # 'file.tar'

这和 os.path.splitext 行为一致。如果你想拿到 file,得这样:

p = Path("file.tar.gz")
real_stem = p.name.split(".")[0]  # 'file'
# 或者用 while 循环剥离 suffixes

5. 路径形态转换

这一类的方法都是返回新的 Path 对象,不修改原对象(Path 是不可变的)。

5.1 .resolve() —— 转绝对路径并解析符号链接 (高频)

Path("data.txt").resolve()
# PosixPath('/home/alice/projects/data.txt')

Path("a/b/../c").resolve()
# 自动解析 .. 得到规范的绝对路径

.resolve() 做三件事:

  1. 转绝对路径(基于 cwd)
  2. 解析 .. 和 .
  3. 跟随符号链接(symlink)拿到真实路径

最经典的用法——定位"当前脚本所在目录":

SCRIPT_DIR = Path(__file__).resolve().parent

__file__ 是当前 .py 文件路径(可能是相对的),.resolve() 转绝对,.parent 拿到所在目录。这是 Python 项目里最常见的咒语之一,几乎每个项目的入口文件都有这一行。

5.2 .absolute() —— 只转绝对路径

Path("data.txt").absolute()
# PosixPath('/home/alice/projects/data.txt')

和 .resolve() 的区别:.absolute() 不解析 .. 和符号链接,只是简单地"在前面拼上 cwd"。**多数时候你应该用 ****.resolve()**。

5.3 .expanduser() —— 展开 ~

Path("~/Downloads").expanduser()
# PosixPath('/home/alice/Downloads')

和 os.path.expanduser 一样。如果你写 Path("~/x") 不调 .expanduser(),那个 ~ 不会被展开,会被当作字面量目录名。

5.4 .with_name() / .with_stem() / .with_suffix() —— 替换路径组件

p = Path("/home/alice/report.pdf")

p.with_name("summary.pdf")
# PosixPath('/home/alice/summary.pdf')      替换文件名

p.with_stem("summary")
# PosixPath('/home/alice/summary.pdf')      只替换 stem,保留扩展名

p.with_suffix(".docx")
# PosixPath('/home/alice/report.docx')      只替换扩展名

p.with_suffix("")
# PosixPath('/home/alice/report')           去掉扩展名

这一组方法非常有用,避免你手动拆字符串。例子:把所有 .csv 改成对应的 .json:

csv_path = Path("data/raw/sales.csv")
json_path = csv_path.with_suffix(".json")
# PosixPath('data/raw/sales.json')

补充说明:.with_stem() 是 Python 3.9 才加的。3.8 及更早只能用 .with_name(p.stem + "_new" + p.suffix)。

5.5 .relative_to() —— 计算相对路径

p = Path("/home/alice/projects/data.txt")
base = Path("/home/alice")

p.relative_to(base)
# PosixPath('projects/data.txt')

把 p 写成相对于 base 的形式。常用于打印日志时不想显示完整绝对路径,只显示项目内的相对路径。

陷阱:如果 p 不是 base 的子路径,会抛 ValueError。

Path("/etc/passwd").relative_to("/home")  # 错误: ValueError

6. 判断与查询

p = Path("file.txt")

p.exists()         # 是否存在(任何类型)
p.is_file()        # 是否是普通文件
p.is_dir()         # 是否是目录
p.is_symlink()     # 是否是符号链接
p.is_absolute()    # 是否是绝对路径

p.stat()           # 拿元信息(os.stat() 的对象式包装)
p.stat().st_size   # 字节数
p.stat().st_mtime  # 最后修改时间(Unix 时间戳)

**.exists()**** 的陷阱**:对坏的符号链接返回 False。如果你想知道路径节点本身是否存在(不管链接是否有效),用 .is_symlink() 配合判断。

性能注意:每次调 .exists()、.is_file() 都会发一次系统调用。在循环里频繁调用会慢。如果要批量处理,优先用 iterdir()/scandir 一次拿到所有元信息。

7. 文件系统操作

pathlib 把文件操作做成了 Path 对象的方法。

7.1 创建

p = Path("logs")
p.mkdir()                                 # 创建单层目录
p.mkdir(parents=True, exist_ok=True)      # 多层 + 已存在不报错(标配)

# 创建空文件
Path("hello.txt").touch()
Path("hello.txt").touch(exist_ok=False)   # 已存在则报错

parents=True 和 exist_ok=True 这两个参数几乎每次都要带,原因前面讲过。

7.2 删除

Path("file.txt").unlink()                  # 删文件,不存在会报错
Path("file.txt").unlink(missing_ok=True)   # 不存在也不报错(3.8+)

Path("empty_dir").rmdir()                  # 删空目录

注意:pathlib没有递归删除整棵目录树的方法。要删整个目录得用 shutil.rmtree(下个模块)。这是 pathlib 的设计哲学——它管路径和单个节点,批量操作交给 shutil。

7.3 重命名/移动

Path("old.txt").rename("new.txt")          # 重命名
Path("old.txt").rename(Path("backup") / "old.txt")  # 移动到子目录
Path("a.txt").replace("b.txt")             # 强制覆盖(已存在不报错)

陷阱:rename 跨磁盘会失败(操作系统限制)。跨磁盘移动用 shutil.move。

补充说明:rename() 在 Python 3.8 之前返回 None,3.8 之后返回新路径的 Path 对象。所以你看老代码不要 new_p = p.rename(...),新代码可以这样链式用。

8. 内容读写(重头戏)

pathlib 把"读文件/写文件"做成了一行调用。

8.1 文本读写

p = Path("hello.txt")

# 一行写
p.write_text("Hello, world!\n", encoding="utf-8")

# 一行读
content = p.read_text(encoding="utf-8")

对比传统写法:

# 传统
with open("hello.txt", "w", encoding="utf-8") as f:
    f.write("Hello, world!\n")

with open("hello.txt", "r", encoding="utf-8") as f:
    content = f.read()

write_text / read_text 内部就是包了一层 open() + 上下文管理器。对小文件极其方便,不需要写 with 语句。

何时不用 **read_text**/**write_text**?

  • 大文件(一次读进内存会爆)→ 用 .open() 配合上下文管理器逐行读
  • 二进制文件 → 用 read_bytes/write_bytes
  • 需要 append 模式 → 用 .open("a")

8.2 二进制读写

p = Path("image.png")

data = p.read_bytes()                  # 一次读全部,返回 bytes
p.write_bytes(b"\x89PNG\r\n...")       # 一次写

8.3 .open() —— 完全等价于内置 open()

with p.open("r", encoding="utf-8") as f:
    for line in f:        # 逐行读,省内存
        print(line.rstrip())

with p.open("a", encoding="utf-8") as f:
    f.write("追加一行\n")

p.open(...) 完全等价于 open(p, ...),只是更面向对象。模式("r"、"w"、"a"、"rb"、"wb")和内置 open 完全一样。

9. 目录遍历

9.1 .iterdir() —— 列出当前目录内容

for child in Path("/tmp").iterdir():
    print(child)
# /tmp/file1.txt
# /tmp/subdir
# /tmp/file2.log

返回的是完整的 Path 对象(不像 os.listdir 只给名字!)。只列一层,不递归。

和 **os.listdir** 的对比:

# os.listdir:只给名字,要拼路径
for name in os.listdir("/tmp"):
    full = os.path.join("/tmp", name)
    ...

# pathlib:直接给 Path 对象
for p in Path("/tmp").iterdir():
    ...    # p 已经是完整路径了

9.2 .glob(pattern) —— 当前目录通配符匹配

# 当前目录下所有 .py
list(Path(".").glob("*.py"))

# 子目录里的 .py(一层)
list(Path(".").glob("*/*.py"))

# 任意层级的 .py(** 表示任意层级)
list(Path(".").glob("**/*.py"))

通配符规则:

模式含义
*匹配任意字符(不含 /
)
**匹配任意层级目录
?匹配单个字符
[abc]匹配字符集合中任一个
[a-z]范围匹配

9.3 .rglob(pattern) —— 递归通配符匹配

list(Path(".").rglob("*.py"))
# 等价于 Path(".").glob("**/*.py")

r = recursive。实际项目中递归找文件用 **rglob** 最简洁。

例子:找出项目里所有的 __pycache__ 目录:

for p in Path(".").rglob("__pycache__"):
    if p.is_dir():
        print(p)

补充说明:.glob() 返回生成器(lazy),所以列大目录不会一次性吃光内存。如果想要列表,就 list(...) 一下。

10. 常见误区汇总

误区 1:以为 Path 是可变的

p = Path("a/b")
p / "c"           # 返回新 Path,原 p 没变!
print(p)          # 还是 a/b
p = p / "c"       # 要这样赋值才有效

Path 是不可变对象,所有"修改"方法都返回新 Path。

误区 2:忘了写 encoding

# Windows 上可能乱码
Path("data.txt").write_text("中文")

# Path("data.txt").write_text("中文", encoding="utf-8")

误区 3:用 Path 做字典 key 时混用相对/绝对路径

d = {}
d[Path("data.txt")] = 1
d[Path("data.txt").resolve()] = 2
# 这是两个不同的 key!

Path 的 == 比较的是字符串形式,相对和绝对不相等。涉及路径作为标识符时,统一调一次 .resolve()。

误区 4:以为 pathlib 替代 os 全部功能

pathlib 只替代了 os.path + 部分文件操作。环境变量、当前目录切换、进程信息这些还得用 os。

误区 5:递归删除目录用 pathlib

pathlib 没有递归删除目录的方法,只能 shutil.rmtree。

11. PurePath / PosixPath / WindowsPath 的关系(速览)

PurePath
├── PurePosixPath        只处理路径字符串,不碰文件系统
└── PureWindowsPath
Path
├── PosixPath            既能处理字符串,又能操作文件系统
└── WindowsPath
  • PurePath 系列:只做路径计算,不能调用 .exists()、.read_text() 这种碰真实文件系统的方法。主要用于跨平台解析——比如你在 Mac 上想解析一个 Windows 路径字符串,用 PureWindowsPath。
  • Path 系列:常规使用,能干所有事。

99% 的场景你用 Path 就够了。PurePath 只在特殊跨平台需求才用。

面试题:Path("a") == PurePath("a") 是 True 还是 False?答:True。Path 继承自 PurePath,比较时比的是字符串。

12. 综合代码实践

实现 Day 1 开头那个"整理 Downloads 文件夹"的 pathlib 版本,并加上更多功能:

from pathlib import Path
import shutil

def organize_downloads(target_dir: Path, dry_run: bool = True):
    """按扩展名整理目录"""
    
    # 1. 检查目录存在
    if not target_dir.is_dir():
        raise NotADirectoryError(f"目录不存在: {target_dir}")
    
    # 2. 分类规则
    rules = {
        ".pdf": "PDF",
        ".jpg": "图片", ".jpeg": "图片", ".png": "图片", ".gif": "图片",
        ".mp3": "音乐", ".wav": "音乐",
        ".zip": "压缩包", ".tar": "压缩包", ".gz": "压缩包", ".7z": "压缩包",
        ".txt": "文本", ".md": "文本", ".doc": "文本", ".docx": "文本",
    }
    
    # 3. 收集要移动的操作
    moves = []  # [(源, 目标), ...]
    for entry in target_dir.iterdir():
        if not entry.is_file():
            continue
        category = rules.get(entry.suffix.lower(), "其他")
        target = target_dir / category / entry.name
        moves.append((entry, target))
    
    # 4. 打印计划
    print(f"扫描完成:{len(moves)} 个文件待整理")
    print(f"模式:{'DRY RUN' if dry_run else '实际执行'}\n")
    
    # 5. 执行(或预览)
    for src, dst in moves:
        action = "[DRY RUN]" if dry_run else ""
        print(f"{action} {src.name}  →  {dst.parent.name}/")
        
        if not dry_run:
            dst.parent.mkdir(parents=True, exist_ok=True)  # 确保目标目录存在
            shutil.move(src, dst)
    
    print(f"\n完成。")


if __name__ == "__main__":
    organize_downloads(Path.home() / "Downloads", dry_run=True)

逐段讲解:

  1. 类型注解target_dir: Path:明确告诉调用者要传 Path 对象。Python 不会强制检查,但 IDE 和类型检查器会帮你
  2. **is_dir()**** 检查**:fail fast,发现问题立刻报错
  3. **entry.suffix.lower()**:扩展名要转小写,避免 .PDF 和 .pdf 不匹配
  4. **rules.get(ext, "其他")**:字典查询带默认值
  5. 先收集后执行:和 os.walk 那个练习一样的模式,永远先收集再执行,这样 dry run 才能完整预览
  6. **dst.parent.mkdir(parents=True, exist_ok=True)**:写入前确保目标目录存在,这是 pathlib 的标准操作
  7. **if __name__ == "__main__":**:让这个脚本既能直接跑,也能被别人 import 复用(Day 5 会再讲)

13. 练习题

理论题

  1. Path("a/b/c.tar.gz").stem 是什么?.suffix 呢?.suffixes 呢?为什么 .stem 不是 'c'?
回答

1、.stem返回最后一个后缀名前面的内容,这里是c.tar

2、.suffix返回最后面的后缀名,这里是.gz

3、.suffixes返回所有的后缀名的列表,这里是['.tar', '.gz']

pathlib 中 stem、suffix 与 suffixes 的结果

2. `Path` 是可变还是不可变对象?`p / "x"` 会修改 `p` 吗?
回答

不可变对象

3. `.resolve()` 和 `.absolute()` 的区别是什么?什么时候选哪个?
回答

Path.resolve 与 Path.absolute 的结果对比

4. `Path("a") / "/b"` 的结果是什么?为什么?
回答

这个从会忽略掉前面的**”a”**,我的理解是没根目录就直接拼接,有根目录就从根目录往后算

5. `pathlib` 为什么没有递归删除目录的方法?
回答

我不知道

pathlib 删除目录的行为示例

6. `Path.cwd()` 和 `Path(__file__).resolve().parent` 有什么区别?哪个更适合做"项目根目录"?
回答

我不知道

当前工作目录与脚本目录的区别

#### 代码实操题 **题目**:写一个脚本 `find_large_files.py`:
  • 输入:一个目录路径(先硬编码)
  • 功能:递归扫描该目录,找出大于 1 MB 的所有文件
  • 输出:按大小从大到小排序,格式如下:
123.45 MB  /path/to/big_video.mp4
67.89 MB   /path/to/dataset.zip
...

要求:

  • 全程使用 pathlib,不准用 os.walk 或 os.path
  • 输出的大小保留两位小数
  • 输出的路径使用相对于扫描根目录的路径(用 .relative_to)

提示:

  • Path.rglob("*") 拿到所有内容(包括目录),用 is_file() 过滤
  • p.stat().st_size 拿字节数
  • 先收集成 [(size, path), ...] 列表,用 sorted(..., key=lambda x: x[0], reverse=True) 排序
  • 字节数 → MB:size / (1024 * 1024)
  • 格式化字符串:f"{mb:.2f} MB"
回答
def find_large_files(target_dir: Path):
    """
    Find large files in the current directory.
    """
    print(f"Finding large files in {target_dir}...")
    large_files=[]
    if  not target_dir.is_dir():
        raise NotADirectoryError(f"{target_dir} is not a directory")
    for file in target_dir.rglob("*"):
        if file.is_file() and file.stat().st_size > 1000000:
            size = file.stat().st_size
            size_in_mb = size / (1024 * 1024)
            large_files.append((size_in_mb, file))

    large_files.sort(key=lambda x: x[0], reverse=True)
    for size, file in large_files:
        print(f"{size:.2f} MB: {file}")

if __name__ == "__main__":
    find_large_files(Path.cwd())
#### 思考题 你的项目里有这样一个需求:**用户提供一个文件名,你要保存到 **`**data/uploads/**`** 目录下**。例如用户传 `"my report.pdf"`,你保存为 `data/uploads/my report.pdf`。

但有用户恶意传 "../../etc/passwd",于是你的代码可能会写到 /etc/passwd 把系统文件覆盖了——这叫路径穿越攻击(Path Traversal)。

请思考:

  1. 如果用 data_dir / user_filename 这种 pathlib 写法,会发生什么?Path("data/uploads") / "../../etc/passwd" 的结果是什么?
回答
>>> Path("data/uploads") / "../../etc/passwd"
WindowsPath('data/uploads/../../etc/passwd')
>>> 
2. 怎么用 `pathlib` 的方法来检测和阻止这种攻击?关键词:`.resolve()` 和 `.is_relative_to()`(Python 3.9+)
回答
>>> data_dir = Path("data/uploads")
>>> user_filename_01 = "my report.pdf"
>>> user_filename_02 = "../../etc/passwd"
>>> user_file_01 = data_dir / user_filename_01
>>> user_file_02 = data_dir / user_filename_02
>>> user_file_01.resolve().is_relative_to(data_dir.resolve())
True
>>> user_file_02.resolve().is_relative_to(data_dir.resolve())
False
3. 文件名里如果有 `/`、`\`、`:` 这些字符,跨平台会出什么问题?怎么"清洗"用户输入的文件名?
回答

从源头禁止,规范用户输入

不需要现在写完代码,先在脑子里把流程理清楚。这是真实项目的安全问题,所有处理用户上传的网站都要解决。

14. 模块小结

**pathlib**** 的核心思想**:路径不是字符串,是 Path 对象。

最常用的 10 个东西(必须背下来):

  • Path("...") —— 创建
  • Path.home() / Path.cwd() —— 起点
  • / —— 拼接
  • .parent, .name, .stem, .suffix —— 拆分
  • .resolve() —— 转绝对路径
  • .exists(), .is_file(), .is_dir() —— 判断
  • .mkdir(parents=True, exist_ok=True) —— 创建目录
  • .read_text(encoding="utf-8") / .write_text(..., encoding="utf-8") —— 一行读写
  • .iterdir() —— 列目录
  • .rglob("*.xxx") —— 递归查找

经典咒语:

PROJECT_ROOT = Path(__file__).resolve().parent

和明天/下个模块的衔接:

  • 你已经能熟练操作单个文件和路径
  • 但整棵目录树的复制、移动、压缩、删除pathlib 不管,下个模块 shutil 接管
  • pathlib + shutil = 完整的文件系统操作能力

模块 4 / 5:shutil

1. 问题引入

你之前两个模块学了 pathlib,能优雅地处理单个文件、单个目录。但真实项目里你经常要做这些事:

  • 备份:把 data/ 整个目录原样复制一份到 backup/data/
  • 部署:把整个项目目录从 dev/ 移动到 prod/(可能跨磁盘)
  • 清理:递归删除整个 __pycache__/、.git/、node_modules/
  • 打包:把整个项目目录压成 myproject.zip 发给同事
  • 空间检查:部署前看看磁盘还剩多少 GB

这些操作 pathlib 都不管——它只管单节点。shutil 负责"批量"和"树形"操作。

名字解释:shutil = shell utilities,模仿 Unix shell 命令(cp -r、mv、rm -rf、du)的功能。

2. 模块定位

shutil 的核心职责:对文件和目录树做批量、树形、跨磁盘的操作。它和前面模块的分工:

单个路径节点          → pathlib(拼接、判断、读写)
跨节点的简单操作      → os(删空目录、重命名)
跨节点的复杂操作      → shutil(递归复制/删除、压缩、跨磁盘移动)

shutil 的能力可以分成 5 大类:

类别典型函数重要性
复制copy
, copy2
, copyfile
, copytree
高频
移动与删除move
, rmtree
高频
压缩与解压make_archive
, unpack_archive
中频
磁盘信息disk_usage中频
其他工具which
, chown
, copyfileobj
低频

3. 复制

shutil 提供了5 个复制函数,新人会问"为啥这么多"。其实它们覆盖不同场景

3.1 五个复制函数对比表

函数复制内容复制元信息接受目录作为目标递归复制目录
shutil.copyfile(src, dst)内容不复制必须是文件名不递归
shutil.copy(src, dst)内容 + 权限部分(权限)是不递归
shutil.copy2(src, dst)内容 + 权限 + 时间戳几乎全部是不递归
shutil.copytree(src, dst)整个目录树默认用 copy2—是
shutil.copyfileobj(fsrc, fdst)内容(已打开的文件对象之间)不复制—不递归

人话翻译:

  • 只复制内容、什么都不要 → copyfile
  • 复制文件并保留权限(最常用) → copy
  • 复制文件且尽可能保留所有元信息(推荐默认选这个) → copy2
  • 复制整个目录树 → copytree
  • 在已打开的文件流之间拷贝(如下载、加密) → copyfileobj

推荐口诀:"日常用 **copy2**,目录用 **copytree**"。

3.2 shutil.copy2(src, dst) —— 推荐的默认选择

import shutil
from pathlib import Path

# 文件 → 文件
shutil.copy2("source.txt", "backup.txt")

# 文件 → 目录(自动用源文件名)
shutil.copy2("source.txt", "backup/")
# 等价于复制到 backup/source.txt

# Path 对象也行
src = Path("data/raw/sales.csv")
dst = Path("data/backup/")
shutil.copy2(src, dst)

为什么默认推荐 **copy2** 而不是 **copy**?

copy2 的 "2" 表示"第二代"——它在 copy 的基础上额外保留了文件的修改时间(mtime)和访问时间(atime)。区别在于:

# copy 之后:备份文件的修改时间是"现在"
# copy2 之后:备份文件的修改时间和源文件一样

# 这影响什么?
# - 增量备份工具(rsync、tar)依赖 mtime 判断是否需要重新备份
# - 你想知道"这个备份是 2024 年的版本",靠 mtime
# - 用 copy 备份后,所有文件的 mtime 都成了备份当天,原始信息丢失

所以99% 的情况选 **copy2**,除非你明确知道为什么不要时间戳。

3.3 shutil.copytree(src, dst) —— 复制整个目录树 (高频)

shutil.copytree("data/raw", "data/backup_raw")

把 data/raw 整棵目录(包括所有子目录和文件)原样复制到 data/backup_raw。

重要陷阱:目标目录必须不存在!否则抛 FileExistsError。

# 如果 backup/ 已经存在,会报错
shutil.copytree("data", "backup")

# 解决方法 1:先删除目标
if Path("backup").exists():
    shutil.rmtree("backup")
shutil.copytree("data", "backup")

# 解决方法 2:Python 3.8+ 的 dirs_exist_ok 参数
shutil.copytree("data", "backup", dirs_exist_ok=True)
# 目标已存在不报错,会合并

为什么默认禁止覆盖?——这是有意为之的安全设计。复制目录树是个"重操作",万一目标是 /etc 或 ~,覆盖就毁了。强制要求"目标不存在"逼你显式处理这种情况。

copytree 的高级参数:ignore 过滤

# 复制时跳过 .git、__pycache__、.venv
def ignore_patterns(src, names):
    return {n for n in names if n in (".git", "__pycache__", ".venv")}

shutil.copytree("myproject", "backup", ignore=ignore_patterns)

ignore 是一个回调函数,对每个目录调用一次,返回"要忽略的名字集合"。shutil 已经内置了一个工厂:

shutil.copytree("myproject", "backup",
    ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc", "*.log"))

shutil.ignore_patterns(*patterns) 返回一个符合 ignore 接口的函数,模式是 glob 通配符(不是正则)。

补充说明:shutil.ignore_patterns 是常用工具,记住它。背后用的就是上一节学过的 fnmatch 通配符规则。

3.4 shutil.copyfileobj(fsrc, fdst) —— 流之间复制 (低频)

这个函数特殊:输入不是路径,是已打开的文件对象(或任何 file-like object)。

with open("source.bin", "rb") as fsrc, open("dest.bin", "wb") as fdst:
    shutil.copyfileobj(fsrc, fdst)

看起来好像没必要——直接 fdst.write(fsrc.read()) 不就行了?区别在于:

# 一次读全部到内存,10 GB 文件直接 OOM
fdst.write(fsrc.read())

# copyfileobj 内部分块读写,默认 64KB 一块,处理任意大小文件不爆内存
shutil.copyfileobj(fsrc, fdst)

典型场景:网络下载、文件上传、解密/加密流处理。

# 下载文件的标准模式
import urllib.request
with urllib.request.urlopen("https://example.com/big.zip") as resp, \
     open("local.zip", "wb") as out:
    shutil.copyfileobj(resp, out)

这是 Day 6 网络模块会再用到的模式,现在先有印象。

4. 移动与删除

4.1 shutil.move(src, dst) —— 移动 (高频)

shutil.move("old/file.txt", "new/file.txt")
shutil.move("old_dir", "archive/old_dir")

和 os.rename 比有什么优势?**shutil.move**** 能跨磁盘**。

# os.rename:跨磁盘失败
os.rename("/home/file.txt", "/mnt/usb/file.txt")
# OSError: [Errno 18] Invalid cross-device link

# shutil.move:自动判断
shutil.move("/home/file.txt", "/mnt/usb/file.txt")

shutil.move 内部逻辑:

  1. 先尝试 os.rename(同磁盘下极快,只是改个目录项)
  2. 如果失败(跨磁盘) → 用 copy2 + 删除源 的方式实现"移动"

所以:

  • 同磁盘下,shutil.move ≈ os.rename,性能一样
  • 跨磁盘时,shutil.move 自动处理,os.rename 报错

结论:**新代码移动文件统一用 ****shutil.move**,不要纠结 os.rename。

陷阱:移动目录时目标已存在

# 如果 archive/old_dir 已存在
shutil.move("old_dir", "archive/old_dir")
# 行为:把 old_dir 移动到 archive/old_dir/old_dir 里!
# 因为它把 dst 当成了"父目录"

这个行为和 Linux **mv** 命令一致。如果你想要"覆盖",得自己先删 archive/old_dir。

4.2 shutil.rmtree(path) —— 递归删除整棵目录树 (高频)

这是 shutil 里最危险的函数。

shutil.rmtree("backup_2023")   # 整棵 backup_2023 连同里面所有文件全删

为什么危险?

  • 没有回收站——直接从磁盘上删除,无法恢复
  • 没有任何确认提示
  • 一次操作可能删几百 GB

调用前必须做的检查:

target = Path("backup_2023")

# 1. 不能是绝对根 / 用户主目录这种敏感路径
if str(target.resolve()) in ("/", str(Path.home())):
    raise ValueError("拒绝删除根目录")

# 2. 必须存在且是目录
if not target.is_dir():
    raise NotADirectoryError(target)

# 3. 最好先打印将删除的内容,让用户确认(dry run)
for p in target.rglob("*"):
    print(f"将删除: {p}")
input("回车确认删除,Ctrl+C 取消...")

shutil.rmtree(target)

**rmtree**** 的可选参数**:

# ignore_errors=True:忽略权限错误等异常,强行往下删
shutil.rmtree("path", ignore_errors=True)

# onerror:自定义错误回调(更细粒度的控制)
def on_error(func, path, exc_info):
    print(f"删除失败: {path}, 用 chmod 后重试")
    os.chmod(path, 0o777)
    func(path)  # 重试

shutil.rmtree("path", onerror=on_error)

经典坑:在 Windows 上删 git 仓库时,.git 里的某些文件是只读的,直接 rmtree 会失败。onerror 回调改权限后重试是标准方案。

补充说明:Python 3.12 把 onerror 改名成 onexc 并改了签名(参数从 (func, path, exc_info) 变成 (func, path, exception))。但 onerror 仍然支持以保持兼容。新代码可以用 onexc。

5. 压缩与解压

5.1 shutil.make_archive(base_name, format, root_dir) —— 一行打包

# 把 myproject/ 打包成 backup.zip
shutil.make_archive("backup", "zip", "myproject")
# 生成 backup.zip 文件

参数:

  • base_name:输出文件名(不带扩展名!它会自动加)
  • format:"zip"、"tar"、"gztar"、"bztar"、"xztar"
  • root_dir:要打包的目录

完整例子:

import shutil
from pathlib import Path

# 打包项目
output = shutil.make_archive(
    base_name="releases/myapp-v1.0",   # 输出 releases/myapp-v1.0.zip
    format="zip",
    root_dir="myapp",
)
print(f"打包完成: {output}")

支持的格式(取决于系统装了哪些库):

shutil.get_archive_formats()
# [('bztar', "bzip2'ed tar-file"),
#  ('gztar', "gzip'ed tar-file"),
#  ('tar', 'uncompressed tar file'),
#  ('xztar', "xz'ed tar-file"),
#  ('zip', 'ZIP file')]

5.2 shutil.unpack_archive(filename, extract_dir) —— 一行解压

shutil.unpack_archive("backup.zip", "restore/")

自动根据扩展名识别格式。

何时不用 shutil 的压缩功能? 标准库里更专业的模块是 zipfile、tarfile、gzip,它们提供更细粒度的控制(按文件添加、读单个成员、流式压缩)。shutil 的压缩函数只是它们的"一行简化包装",适合快速打包整个目录,不适合复杂场景。比如要从 zip 里只读出某一个文件而不解压整个包,得用 zipfile。

6. 磁盘信息

shutil.disk_usage(path) —— 查看磁盘空间 (中频)

usage = shutil.disk_usage("/")
print(usage)
# usage(total=500107862016, used=300064575488, free=200043286528)

print(f"总: {usage.total / 1024**3:.1f} GB")
print(f"已用: {usage.used / 1024**3:.1f} GB")
print(f"剩余: {usage.free / 1024**3:.1f} GB")

返回一个具名元组(NamedTuple),可以 .total、.used、.free 访问。单位是字节。

典型场景:

  • 部署前检查磁盘够不够
  • 备份脚本检查目标磁盘
  • 监控脚本写日志
# 部署检查
required_gb = 5
free_gb = shutil.disk_usage("/").free / 1024**3
if free_gb < required_gb:
    raise RuntimeError(f"磁盘不足:剩余 {free_gb:.1f} GB,需要 {required_gb} GB")

7. 其他工具

7.1 shutil.which(cmd) —— 找命令的位置(类似 Unix which)(中频)

shutil.which("python")
# '/usr/bin/python'

shutil.which("git")
# '/usr/local/bin/git'

shutil.which("nonexistent")
# None

用途:判断系统有没有装某个外部工具,没装就报错或换方案。

if shutil.which("ffmpeg") is None:
    raise RuntimeError("请先安装 ffmpeg")

Day 6 讲 subprocess 调用外部命令时会再用到。

7.2 shutil.chown(path, user, group) —— 改所有者(仅 Unix)(低频)

shutil.chown("file.txt", user="alice", group="users")

比 os.chown(要传数字 uid/gid)更友好——可以直接用用户名。Windows 上不可用。

8. 综合代码实践

实现一个**"项目备份工具"**,把 Day 1 学的全部缝起来:

from pathlib import Path
import shutil
from datetime import datetime

def backup_project(
    project_dir: Path,
    backup_root: Path,
    compress: bool = True,
    dry_run: bool = False,
):
    """备份一个项目目录。
    
    - 跳过常见的"派生文件"目录
    - 加日期后缀
    - 可选打包成 zip
    - 备份前检查磁盘空间
    - 支持 dry run
    """
    # 1. 安全检查
    if not project_dir.is_dir():
        raise NotADirectoryError(f"项目目录不存在: {project_dir}")
    
    backup_root.mkdir(parents=True, exist_ok=True)
    
    # 2. 估算项目大小,检查磁盘空间
    total_size = sum(f.stat().st_size for f in project_dir.rglob("*") if f.is_file())
    free = shutil.disk_usage(backup_root).free
    if free < total_size * 1.5:   # 留 1.5 倍冗余
        raise RuntimeError(
            f"磁盘空间不足:项目 {total_size / 1024**2:.1f} MB,"
            f"剩余 {free / 1024**2:.1f} MB"
        )
    
    # 3. 生成备份名
    date_tag = datetime.now().strftime("%Y%m%d-%H%M%S")
    backup_name = f"{project_dir.name}-{date_tag}"
    backup_path = backup_root / backup_name
    
    # 4. 打印计划
    print(f"项目: {project_dir}")
    print(f"备份到: {backup_path}{'.zip' if compress else '/'}")
    print(f"项目大小: {total_size / 1024**2:.1f} MB")
    print(f"磁盘剩余: {free / 1024**3:.1f} GB")
    print(f"模式: {'DRY RUN' if dry_run else '实际执行'}\n")
    
    if dry_run:
        print("[DRY RUN] 跳过实际操作")
        return
    
    # 5. 复制(跳过派生文件)
    print("复制中...")
    shutil.copytree(
        project_dir,
        backup_path,
        ignore=shutil.ignore_patterns(
            "__pycache__", "*.pyc", ".git", ".venv",
            "node_modules", "*.log", ".DS_Store",
        ),
    )
    print(f"复制完成: {backup_path}")
    
    # 6. 可选:压缩
    if compress:
        print("压缩中...")
        archive = shutil.make_archive(
            base_name=str(backup_path),
            format="zip",
            root_dir=backup_root,
            base_dir=backup_name,
        )
        print(f"压缩完成: {archive}")
        
        # 删除未压缩的副本
        shutil.rmtree(backup_path)
        print(f"已删除中间目录")
    
    print("\n备份完成。")


if __name__ == "__main__":
    backup_project(
        project_dir=Path.cwd(),
        backup_root=Path.home() / "backups",
        compress=True,
        dry_run=False,
    )

这段代码用到了 Day 1 几乎所有知识:

用到的功能来自
Path.is_dir()
, Path.cwd()
, /
拼接
pathlib
mkdir(parents=True, exist_ok=True)pathlib
Path.rglob("*")
计算目录大小
pathlib
Path.stat().st_sizepathlib
Path.name
拿文件夹名
pathlib
shutil.disk_usage()shutil
shutil.copytree(..., ignore=...)shutil
shutil.ignore_patterns(...)shutil
shutil.make_archive(...)shutil
shutil.rmtree(...)shutil
datetime
生成时间戳
Day 3 会讲
dry_run 模式上次练习教训

几个关键点:

  • 第 2 步先估算大小再做,避免半路磁盘满了留下脏数据
  • 第 4 步先打印计划再执行,符合"先 dry run 再真做"的工程习惯
  • **base_dir=backup_name** 是 make_archive 的细节——它让 zip 包内有一个顶层目录(解压时不会铺满当前目录)

9. 常见误区汇总

误区 1:copy 和 copy2 不分

新人随便选一个,结果备份完发现时间戳全乱了。**默认选 ****copy2**。

误区 2:copytree 目标已存在直接报错懵了

# shutil.copytree("a", "b")   # b 已存在 → FileExistsError

# Python 3.8+
shutil.copytree("a", "b", dirs_exist_ok=True)

误区 3:rmtree 当作"清空目录"用

# 想"清空 logs/" 但不删 logs/ 本身
shutil.rmtree("logs")    # logs 目录本身也被删了!

# 清空但保留外壳
for child in Path("logs").iterdir():
    if child.is_file():
        child.unlink()
    else:
        shutil.rmtree(child)

误区 4:make_archive 的 base_name 带扩展名

# 实际生成 backup.zip.zip
shutil.make_archive("backup.zip", "zip", "myproject")

# shutil.make_archive("backup", "zip", "myproject")

误区 5:忘记 shutil.move 跨磁盘语义

跨磁盘移动时,shutil.move 实际是"复制 + 删除",不是原子操作。中途断电可能两边都有部分数据。关键备份不要用 **shutil.move**,先 **copy2** 验证后再删源。

10. 练习题

理论题

  1. shutil.copy、shutil.copy2、shutil.copyfile 三者最关键的区别是什么?为什么默认推荐 copy2?
我的回答

元数据:

# copy 之后:备份文件的修改时间是"现在"
# copy2 之后:备份文件的修改时间和源文件一样

shutil.copy 与 copy2 的元数据差异

2. 为什么 `shutil.move` 比 `os.rename` 更好用?它是怎么做到跨磁盘移动的?
我的回答

能跨磁盘移动

shutil.move 跨目录移动文件的结果

与 copy 不同的是,copytree 时出现目标文件存在的状况,默认是报错的;而 move 默认是塞进去

3. `shutil.copytree` 的目标目录已存在时默认会怎样?怎么改这个行为?
我的回答

copytree 是默认禁止覆盖目标文件的,如果目标文件存在会报错:FileExistsError;

用 dirs_exist_ok=True 来允许

copytree 在目标目录已存在时的处理

4. `shutil.rmtree` 为什么被称为"危险函数"?请列举调用前应该做的至少 3 项检查。
我的回答
  • 他没有回收站的概念,直接删除,没有后悔药
  • 没有任何的二次确认
  • 不对删除大小感冒,一次可能直接删除几十 G

三项检查:

1、路径白名单/黑名单检查

2、类型检查

3、二次确认

5. `shutil.copyfileobj` 和"直接 `fdst.write(fsrc.read())`"有什么区别?什么时候必须用前者?
我的回答

前者分块读取,不占内存

后者一次性读完,占内存

copyfileobj 分块复制与一次性读取的差异

### 代码实操题 **题目**:写一个脚本 `clean_project.py`,对一个项目目录做"轻量化清理":
  • 递归找出并删除所有 __pycache__ 目录、.pyc 文件、.DS_Store 文件、*.log 文件
  • 删除前先打印将要删除的内容和总大小("将释放 XX MB")
  • 支持 DRY_RUN 开关
  • 计算清理前后的目录总大小,输出实际节省的空间
  • 清理过程中跳过 .git/ 目录(不要删 git 内部的东西)

要求:

  • 用 pathlib 找文件
  • 删除目录用 shutil.rmtree
  • 用 shutil.disk_usage 输出可用空间变化(可选)
  • 用今天学的 shutil.ignore_patterns 思路(虽然这里是删除,模式判断同理)

提示:

  • 计算目录总大小:sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
  • 注意 .git 路径包含的判断:".git" in path.parts
  • 删除目录时用 shutil.rmtree,删除文件用 Path.unlink() 或 os.remove()
  • 先收集再执行(你已经熟悉这个模式了)
我的实现
### 思考题 你正在写一个"用户头像上传"功能:
  1. 用户上传一个图片文件
  2. 服务器把它保存到 uploads/avatars/{user_id}/avatar.jpg
  3. 同时生成一个缩略图保存到 uploads/avatars/{user_id}/thumb.jpg
  4. 如果用户已有头像,新头像要原子地替换——也就是要么完全替换成功,要么保持原样,绝不能出现"新头像写到一半失败、旧头像已经被删"的状态

请思考:

  1. 直接 shutil.move(new, old) 能做到原子替换吗?同磁盘和跨磁盘行为有差异吗?
我的回答

不能,因为 move 没有留备份,是直接移动,如果碰到异常状态,无法有复原的可能

2. `shutil.copy2` + `os.replace` 组合呢?
我的回答

我没理解你的说法是什么意思,os.replace 这个与 shutil.move 是一样的,所以估计不行,但是直接复制不就可以了,即保留了原图,也能替换头像

3. 标准的"原子替换"模式是什么样的?提示:先写到临时文件,再 rename。 4. 为什么 `os.replace` 在同一文件系统下是原子操作而 `shutil.move` 不是? 5. 如果机器中途断电(极端场景),怎么保证文件系统不留下"半成品"?
思考题

重点补充:原子替换(这是工程基本功)

什么是"原子操作"

原子操作 = 要么完全成功,要么完全没发生,不存在"做到一半"的状态。

文件系统层面,os.rename 在同一文件系统下是原子的。这是操作系统(具体是文件系统驱动)保证的——重命名一个文件本质上只是改一个目录项里的指针,是单次磁盘写,要么成功要么失败,不可能"改了一半"。

为什么 shutil.move 不一定原子?

回忆它的内部逻辑:

1. 尝试 os.rename(src, dst)         ← 这一步是原子的
2. 如果失败(跨盘):
    a. 复制 src 内容到 dst           ← 写很多字节,不是原子
    b. 删除 src                      ← 又一个独立操作

跨磁盘走 a+b 路径,中间任何一步出错都会留下半成品:

  • a 写到一半断电 → dst 是部分内容
  • a 完成、b 失败 → src 和 dst 都存在(重复了)

标准的"原子写文件"模式

这是工程上保证文件写入安全的圣经模式:

import os
from pathlib import Path

def atomic_write(target: Path, content: bytes):
    """原子地写文件——要么完整写入,要么文件保持原样"""
    tmp = target.with_suffix(target.suffix + ".tmp")
    
    # 第 1 步:写到临时文件(即使中途失败,目标文件原封不动)
    tmp.write_bytes(content)
    
    # 第 2 步:原子替换(os.replace 跨平台都是原子的)
    os.replace(tmp, target)

为什么这个流程是安全的?

  • 第 1 步如果中途断电:tmp 是半成品,但目标文件没变。重启后清理 tmp 即可。
  • 第 1 步如果完全成功,第 2 步是单一原子操作:要么 target 变成新内容,要么保持旧内容。不存在中间态。

临时文件原子替换目标文件的流程

头像上传的完整安全流程

import os
import shutil
from pathlib import Path

def safe_upload_avatar(user_id: str, uploaded_file_path: Path):
    """安全地替换用户头像,保证原子性"""
    
    avatar_dir = Path(f"uploads/avatars/{user_id}")
    avatar_dir.mkdir(parents=True, exist_ok=True)
    
    target = avatar_dir / "avatar.jpg"
    tmp = avatar_dir / "avatar.jpg.uploading"
    
    try:
        # 1. 复制到临时位置
        shutil.copy2(uploaded_file_path, tmp)
        
        # 2. 原子替换
        os.replace(tmp, target)
        
        # 3. 处理缩略图(同样模式)
        thumb_target = avatar_dir / "thumb.jpg"
        thumb_tmp = avatar_dir / "thumb.jpg.uploading"
        generate_thumbnail(target, thumb_tmp)
        os.replace(thumb_tmp, thumb_target)
        
    finally:
        # 清理可能残留的临时文件(比如出错时)
        for f in [tmp, thumb_tmp]:
            if f.exists():
                f.unlink()

第 5 题答案:断电怎么办

文件系统级别还有一道防线叫 fsync:

with open(tmp, "wb") as f:
    f.write(content)
    f.flush()           # 推送到操作系统内核
    os.fsync(f.fileno()) # 强制操作系统把数据写到物理磁盘

os.replace(tmp, target)

不加 fsync 时,"写入完成"只表示数据到了 OS 缓存,还没真正落盘——断电会丢。fsync 强制刷盘。这是数据库、消息队列、日志系统的基本套路。

补充说明:Maybe Useful:第三方库 atomicwrites 把这套流程封装好了。生产代码很多直接用它。但你应该先理解原理,再用工具。

本话题的工程价值

这套"临时文件 + rename + fsync"是所有正经文件操作的范式:

  • SQLite 写数据库就是这样
  • Git 写对象就是这样
  • 日志库(log rotate)轮转日志也是这样
  • 配置文件保存(VS Code、浏览器书签)都这样

面试常考:手写一个"安全保存配置"函数,考的就是这套。

不需要写代码,把流程在脑子里走一遍。这是"事务性文件操作"的入门,所有写数据库、写日志、写配置的库底层都用类似套路。

11. 模块小结

**shutil**** 的核心定位**:批量、树形、跨磁盘的文件系统操作。补足 pathlib 不做的事。

必背 5 个函数:

  • shutil.copy2(src, dst) —— 复制单文件(默认选)
  • shutil.copytree(src, dst, dirs_exist_ok=True) —— 复制整棵目录
  • shutil.move(src, dst) —— 移动(跨磁盘 OK)
  • shutil.rmtree(path) —— 递归删除整棵目录(危险!)
  • shutil.make_archive(name, "zip", root_dir) —— 一行打包

了解 5 个函数:

  • shutil.copyfileobj —— 流之间复制
  • shutil.unpack_archive —— 解压
  • shutil.disk_usage —— 磁盘空间
  • shutil.which —— 找命令位置
  • shutil.ignore_patterns —— 复制时忽略

和下个模块的衔接: 今天我们用 Path.glob/Path.rglob 做了不少模式匹配。下个模块 glob 是这些功能的独立模块版本——很多老代码用它而不是 Path.glob。我们快速过一下,让你能看懂老代码并理解 pathlib 内部用了什么。

模块 5 / 5:glob

1. 问题引入

你已经在 pathlib 里用过 Path.glob("*.py") 和 Path.rglob("*.py") 了。那为什么 Python 还有一个独立的 **glob** 模块?

原因有两个:

  1. **pathlib**** 是 2014 年才有的**,在那之前 Python 已经存在了 20 多年。glob 模块从远古就有,老代码到处都是 glob.glob("*.py"),你必须看得懂。
  2. **glob**** 模块返回的是字符串**(不是 Path 对象),有些场景用字符串更方便——比如直接喂给老 API、做正则匹配、生成 shell 命令。

所以这个模块的学习目标很明确:看懂老代码 + 知道 **pathlib.glob** 内部就是它的封装。

名字解释:glob 来自 Unix 的 "globbing"——通配符匹配文件名的过程。1971 年的 Unix V1 就有 /etc/glob 这个程序专门做这件事。这个名字传承了 50 多年。

2. 模块定位

glob 是个非常小的模块,公开 API 只有 3 个函数:

函数作用重要性
glob.glob(pattern)找匹配的路径,返回列表(高频)
glob.iglob(pattern)同上,返回生成器(省内存)(中频)
glob.escape(s)转义路径里的通配符(低频)

就这三个。glob 不是个大模块,半小时讲完。

3. glob.glob(pattern) —— 核心函数

3.1 基本用法

import glob

# 当前目录所有 .py
glob.glob("*.py")
# ['main.py', 'utils.py', 'test.py']

# 指定目录
glob.glob("/home/alice/projects/*.py")

# 子目录里的 .py(一层)
glob.glob("*/*.py")
# ['src/main.py', 'tests/test_main.py']

注意返回的是字符串列表,不是 Path 对象。这是和 pathlib.Path.glob() 最大的区别。

3.2 通配符规则(和 pathlib.glob 完全一致)

*       匹配任意字符(不含 /)
?       匹配单个字符
[abc]   匹配字符集合
[a-z]   范围匹配
[!abc]  反向匹配(不在集合中)
**      匹配任意层级目录(需开启 recursive=True)

举例:

glob.glob("file?.txt")          # file1.txt, fileA.txt, file_.txt
glob.glob("[abc]*.log")         # a.log, b1.log, c_test.log
glob.glob("[a-z]*.txt")         # 以小写字母开头的 .txt
glob.glob("[!._]*")             # 不以 . 或 _ 开头的(排除隐藏文件)

3.3 递归匹配 ** —— 必须开 recursive=True

这是唯一容易踩的坑:

# 默认下 ** 不递归,等价于 *
glob.glob("**/*.py")
# 只匹配一层,结果可能为空或不全

# 显式打开 recursive
glob.glob("**/*.py", recursive=True)
# 递归匹配所有层级

为什么默认不递归? 历史遗留——recursive=True 是 Python 3.5 才加的。在那之前 glob 不支持递归,要递归只能自己写 os.walk。为了向后兼容,递归功能默认关闭。

补充说明:pathlib.Path.rglob("*.py") 内部就是 glob("**/*.py", recursive=True) 的封装。所以 pathlib 的 rglob 默认就是递归——这是新 API 的"合理默认"修正。

3.4 隐藏文件不会被匹配(除非显式写 .)

# 假设目录里有 a.txt, b.txt, .hidden.txt
glob.glob("*.txt")
# ['a.txt', 'b.txt']  ← .hidden.txt 没被匹配!

glob.glob(".*.txt")
# ['.hidden.txt']

这个行为模仿 Unix shell:* 默认不匹配以 . 开头的隐藏文件。这和 os.listdir 不同(它会列出所有),是新人常见的疑惑点。

3.5 Python 3.11+ 新参数:include_hidden

# Python 3.11+
glob.glob("*.txt", include_hidden=True)
# ['a.txt', 'b.txt', '.hidden.txt']

如果你的 Python ≥ 3.11,可以用这个参数包含隐藏文件。3.10 及之前没有,得自己处理。

4. glob.iglob(pattern) —— 省内存版

import glob

# glob 返回完整列表,一次性读完所有匹配
files = glob.glob("**/*.py", recursive=True)  # list

# iglob 返回生成器,逐个读
for f in glob.iglob("**/*.py", recursive=True):
    process(f)

何时用 **iglob**?

  • 目录非常大(几十万文件)→ 用 iglob 不会一次吃光内存
  • 边找边处理(找到一个处理一个)→ 用 iglob 更早开始工作

何时用 **glob**?

  • 文件数量少 → 列表更方便(能 len、能切片、能排序)
  • 需要先看总数再决定怎么办

99% 的场景用 glob 就行,iglob 知道有这东西。

补充说明:pathlib.Path.glob() 也是返回生成器——这是新 API 的合理默认(lazy)。要列表自己 list(...)。

5. glob.escape(s) —— 转义通配符

这是个冷门但有用的函数。场景:用户输入的文件名里恰好有通配符。

import glob

# 假设有个文件叫 "report[v2].pdf"
glob.glob("report[v2].pdf")
# []  ← 匹配不到![v2] 被当成字符集合 [v, 2]

glob.glob(glob.escape("report[v2].pdf"))
# ['report[v2].pdf']  ← 转义后能匹配字面量

glob.escape 把字符串里的 *、?、[ 都加上转义,让它们变成字面量字符。

实际用途:你写一个工具接收用户输入的文件名做查找。如果不转义,用户输入 * 可能造成"通配符注入"——类似 SQL 注入的问题。

# 不安全
def find_file(name):
    return glob.glob(f"data/{name}")

find_file("*")    # 列出整个 data/,超出预期

# 安全
def find_file(name):
    return glob.glob(f"data/{glob.escape(name)}")

6. 老代码 vs 新代码对照

# 老代码(你会经常看到)
import glob
import os

py_files = glob.glob("**/*.py", recursive=True)
for f in py_files:
    if os.path.getsize(f) > 1000:
        print(f)

# 新代码
from pathlib import Path

for f in Path(".").rglob("*.py"):
    if f.stat().st_size > 1000:
        print(f)

两者功能完全等价,新代码更简洁、面向对象。记忆:

glob
模块
pathlib
等价
glob.glob("*.py")list(Path(".").glob("*.py"))
glob.glob("**/*.py", recursive=True)list(Path(".").rglob("*.py"))
glob.iglob("*.py")Path(".").glob("*.py")
glob.escape(name)(pathlib 没有等价物,要用就 import glob)

7. 顺便认识一下亲戚:fnmatch

glob 有个亲戚叫 fnmatch(filename match),它不查文件系统,只做字符串模式匹配。

import fnmatch

# 检查一个名字是否匹配模式
fnmatch.fnmatch("report.pdf", "*.pdf")        # True
fnmatch.fnmatch("README.md", "*.txt")         # False

# 从一堆名字里筛选
names = ["a.py", "b.txt", "c.py", "d.md"]
fnmatch.filter(names, "*.py")
# ['a.py', 'c.py']

关系:

  • glob = "扫文件系统 + fnmatch 做模式匹配"
  • fnmatch 单独可用——比如你已经有一个文件名列表,只想做模式过滤

典型场景:上个模块用过的 shutil.ignore_patterns:

shutil.copytree("a", "b", ignore=shutil.ignore_patterns("*.pyc", "__pycache__"))

ignore_patterns 内部就是用 fnmatch 做匹配的。

补充说明:3 个相关模块的关系

  • glob —— 扫文件系统 + 模式匹配(要去磁盘看)
  • fnmatch —— 只做模式匹配,对纯字符串
  • re —— 正则表达式(最强大但学习曲线最陡)

通配符匹配本质就是把 glob 模式翻译成正则——fnmatch.translate("*.py") 能看到翻译后的正则字符串:(?s:.*\.py)\Z。

8. 常见误区

误区 1:以为 ** 默认递归

glob.glob("**/*.py")              # 不递归,结果可能为空
glob.glob("**/*.py", recursive=True)  # 才递归

pathlib.Path.glob("**/*.py") 是默认递归的,行为不同——这是新人最常见的混淆。记法:

  • glob 模块的 ** 需要 recursive=True
  • pathlib 的 ** 默认就是递归

误区 2:以为 * 匹配隐藏文件

glob.glob("*")
# 不包含 .git, .DS_Store 这些

要匹配隐藏文件得显式写 .* 或用 Python 3.11+ 的 include_hidden=True。

误区 3:返回顺序不固定

glob.glob("*.txt")
# 顺序依赖文件系统,可能 ['c.txt', 'a.txt', 'b.txt']

如果需要稳定顺序,自己排序:

sorted(glob.glob("*.txt"))

误区 4:路径分隔符问题

# Linux/Mac
glob.glob("data/*.csv")  # 
# Windows 也接受 /,所以同样写法跨平台 OK
glob.glob("data/*.csv")  # Windows 上也行

# 但用反斜杠要注意转义
glob.glob("data\\*.csv")     # glob.glob(r"data\*.csv")     # raw string 更清晰
glob.glob("data\*.csv")      # ⚠️ \* 不是合法转义,但凑巧能跑

跨平台代码推荐统一用 /,或者用 pathlib。

9. 代码实践

例 1:找出所有日志文件并按修改时间排序

import glob
import os

log_files = glob.glob("logs/**/*.log", recursive=True)
log_files.sort(key=os.path.getmtime, reverse=True)  # 最新的在前

for f in log_files[:5]:  # 看最新 5 个
    print(f, os.path.getsize(f), "字节")

pathlib 等价写法:

from pathlib import Path

logs = sorted(
    Path("logs").rglob("*.log"),
    key=lambda p: p.stat().st_mtime,
    reverse=True
)

for f in logs[:5]:
    print(f, f.stat().st_size, "字节")

例 2:统计项目里各类文件数量

import glob
from collections import Counter

# 找出所有文件(递归)
all_files = glob.glob("**/*", recursive=True)
# 注意:** 也会匹配目录,要过滤
import os
all_files = [f for f in all_files if os.path.isfile(f)]

# 按扩展名统计
exts = Counter(os.path.splitext(f)[1] for f in all_files)
for ext, count in exts.most_common(5):
    print(f"{ext or '(无扩展名)'}: {count}")

Counter 来自 collections 模块,Day 4 会专门讲。

例 3:用 fnmatch 做模式过滤

import fnmatch
import os

# 已经有一个文件名列表(不查文件系统)
names = os.listdir("data/")

# 找出所有备份文件
backups = fnmatch.filter(names, "*.bak")
old_logs = fnmatch.filter(names, "log_2023*.txt")

10. 模块小结

**glob**** 的定位**:扫描文件系统 + 通配符匹配。pathlib.glob/rglob 的"前辈"。

必记的 3 件事:

  1. glob.glob(pattern, recursive=True) —— 递归要显式开
  2. 默认不匹配隐藏文件
  3. 返回字符串列表(不是 Path 对象)

新代码不必再用 **glob** 模块,用 pathlib.Path.glob/rglob 即可。但要看懂老代码必须认识它。

亲戚:

  • fnmatch —— 只做字符串模式匹配,不碰磁盘
  • 配合 shutil.ignore_patterns 等场景用