pathlib 替代 os.path: 现代文件路径处理方法
1. 概述
许多旧的 Python 代码使用 os.path.join、os.path.splitext、os.listdir 等处理路径。这些函数接收和返回字符串,导致代码冗长、难以阅读且容易出错。pathlib 模块(从 Python 3.4 开始引入)用 Path 对象表示路径,提供足够的方法来拼接、拆解、读写和遍历文件。
读完本文后,你将能够把大部分 os.path 代码转换为 pathlib,并了解容易踩坑的地方。文中的例子使用 Python 3.12。
2. 创建 Path 对象
Path 是所有操作的起点:
from pathlib import Path
reports = Path("data/reports")
home = Path.home()
cwd = Path.cwd()
print(reports)
print(type(reports))
print(home)
data/reports
<class 'pathlib.PosixPath'>
/home/minh
解释:
Path("data/reports")创建一个路径对象。此时还没有对磁盘执行任何操作。目录不需要存在。- 在 Linux/macOS 上,
Path返回PosixPath。在 Windows 上,它返回WindowsPath。你不需要关心具体的类。 Path.home()和Path.cwd()分别替代os.path.expanduser("~")和os.getcwd()。
如果只需要操作路径字符串而不涉及实际文件,可以使用 PurePath、PurePosixPath 或 PureWindowsPath。这种方法方便在 Linux 上处理 Windows 路径。
3. 使用 / 操作符拼接路径
这是最明显的改变。与其嵌套 os.path.join,不如使用 / 操作符:
import os
from pathlib import Path
# 旧方法
old = os.path.join("data", "orders", "2026", "order_1001.json")
# 新方法
base = Path("data")
new = base / "orders" / "2026" / "order_1001.json"
print(old)
print(new)
print(new == Path(old))
data/orders/2026/order_1001.json
data/orders/2026/order_1001.json
True
/ 操作符在右侧接受 Path 或 str。结果总是 Path,所以你可以继续拼接。分隔符会根据操作系统自动改变:Linux 上是 /,Windows 上是 \。
如果组件数量在运行时变化,你可以使用 joinpath:
parts = ["orders", "2026", "order_1001.json"]
path = Path("data").joinpath(*parts)
print(path) # data/orders/2026/order_1001.json
4. 拆解路径的各个组件
os.path 需要多个函数来完成这个任务:basename、dirname、splitext。而在 pathlib 中,一切都是属性:
from pathlib import Path
invoice = Path("/srv/shop/invoices/2026/invoice_042.final.pdf")
print(invoice.name) # 完整文件名
print(invoice.stem) # 没有末尾扩展名的名称
print(invoice.suffix) # 末尾扩展名
print(invoice.suffixes) # 所有扩展名
print(invoice.parent) # 包含的目录
print(invoice.parents[1])
print(invoice.parts)
invoice_042.final.pdf
invoice_042.final
.pdf
['.final', '.pdf']
/srv/shop/invoices/2026
/srv/shop/invoices
('/', 'srv', 'shop', 'invoices', '2026', 'invoice_042.final.pdf')
解释:
stem和suffix只在最后一个点处分割。对于.tar.gz文件,你需要使用suffixes。parent等同于os.path.dirname。parents是一个目录序列,逐级向上到根目录。parts将路径分割成元组,便于比较各个级别。
要创建具有不同名称或扩展名的新路径,可以使用 with_* 方法:
invoice = Path("invoices/invoice_042.pdf")
print(invoice.with_suffix(".xml")) # invoices/invoice_042.xml
print(invoice.with_stem("invoice_043")) # invoices/invoice_043.pdf
print(invoice.with_name("summary.csv")) # invoices/summary.csv
这些方法不会改变磁盘上的文件名。它们只返回一个新的 Path,因为 Path 是不可变的。
5. 读写文件
对于小文件,你不需要使用 open():
from pathlib import Path
config = Path("app_config.txt")
config.write_text("currency=VND\ntimeout=30\n", encoding="utf-8")
content = config.read_text(encoding="utf-8")
print(content.splitlines())
logo = Path("logo.bin")
logo.write_bytes(b"\x89PNG")
print(logo.read_bytes())
['currency=VND', 'timeout=30']
b'\x89PNG'
write_text打开文件、写入然后关闭。旧文件会被覆盖。read_text将全部内容读入字符串。write_bytes和read_bytes对二进制数据的工作原理类似。
对于大文件或需要逐行读取时,你仍然使用上下文管理器。Path 内置了 open 方法:
with Path("orders.csv").open("w", encoding="utf-8") as f:
f.write("id,amount\n1001,250000\n1002,99000\n")
with Path("orders.csv").open(encoding="utf-8") as f:
total = sum(int(line.split(",")[1]) for line in f if not line.startswith("id"))
print(total) # 349000
注意: 始终传入
encoding="utf-8"。如果省略,Python 将使用系统的默认编码。在 Windows 上,默认通常是cp1252,所以越南语数据会出错。
6. 检查和遍历目录
6.1. 检查状态
from pathlib import Path
p = Path("orders.csv")
print(p.exists(), p.is_file(), p.is_dir())
print(p.stat().st_size)
print(p.resolve())
True True False
36
/home/minh/project/orders.csv
resolve() 返回绝对路径并解析符号链接。它替代了 os.path.abspath 和 os.path.realpath。
6.2. 列举和查找文件
from pathlib import Path
root = Path("shop")
(root / "orders" / "2026").mkdir(parents=True, exist_ok=True)
(root / "orders" / "2026" / "o1.json").write_text("{}", encoding="utf-8")
(root / "orders" / "o0.json").write_text("{}", encoding="utf-8")
(root / "readme.md").write_text("# Shop", encoding="utf-8")
print(sorted(p.name for p in root.iterdir()))
print(sorted(str(p) for p in root.glob("*.md")))
print(sorted(str(p) for p in root.rglob("*.json")))
['orders', 'readme.md']
['shop/readme.md']
['shop/orders/2026/o1.json', 'shop/orders/o0.json']
iterdir()替代os.listdir()。它返回完整的Path,不仅仅是文件名,所以你不需要再拼接。glob("*.md")仅在当前目录中搜索。rglob("*.json")递归搜索,等同于glob("**/*.json")。
从 Python 3.12 起,Path.walk() 可以替代 os.walk():
for dirpath, dirnames, filenames in Path("shop").walk():
print(dirpath, filenames)
7. 创建、重命名和删除
import shutil
from pathlib import Path
backup = Path("backup/daily")
backup.mkdir(parents=True, exist_ok=True)
src = Path("orders.csv")
dst = src.rename(backup / "orders_2026-10-17.csv")
print(dst)
dst.unlink()
Path("not_there.txt").unlink(missing_ok=True)
backup.rmdir()
shutil.rmtree("backup")
backup/daily/orders_2026-10-17.csv
mkdir(parents=True, exist_ok=True)等同于os.makedirs(..., exist_ok=True)。rename返回新的Path。在 Windows 上,如果目标文件已存在,rename会报错。要在所有平台上覆盖,可以使用replace。unlink删除文件。使用missing_ok=True,当文件不存在时不会报错。rmdir只能删除空目录。要删除整个目录树,你仍然需要shutil.rmtree。这个函数接受普通的Path。
8. 快速对比表
| os / os.path | pathlib |
|---|---|
os.path.join(a, b) |
Path(a) / b |
os.path.basename(p) |
p.name |
os.path.dirname(p) |
p.parent |
os.path.splitext(p) |
p.stem, p.suffix |
os.path.exists(p) |
p.exists() |
os.path.isfile(p) / isdir |
p.is_file() / p.is_dir() |
os.path.abspath(p) |
p.resolve() 或 p.absolute() |
os.path.expanduser("~") |
Path.home() 或 p.expanduser() |
os.getcwd() |
Path.cwd() |
os.listdir(p) |
p.iterdir() |
glob.glob("**/*.py", recursive=True) |
p.rglob("*.py") |
os.makedirs(p, exist_ok=True) |
p.mkdir(parents=True, exist_ok=True) |
os.remove(p) |
p.unlink() |
os.rename(a, b) |
a.rename(b) |
os.path.getsize(p) |
p.stat().st_size |
os.path.relpath(p, start) |
p.relative_to(start) |
9. 用 pytest 编写测试
pytest 的 tmp_path fixture 返回一个 Path,所以使用 pathlib 的测试代码非常简洁。假设你有一个函数将 PDF 发票文件归档到一个目录中:
# archive.py
from pathlib import Path
def archive_invoices(source: Path, target: Path) -> list[Path]:
target.mkdir(parents=True, exist_ok=True)
moved = []
for pdf in sorted(source.rglob("*.pdf")):
moved.append(pdf.rename(target / pdf.name))
return moved
对应的测试:
# test_archive.py
from pathlib import Path
from archive import archive_invoices
def test_archive_moves_only_pdf_files(tmp_path: Path):
source = tmp_path / "inbox"
(source / "2026").mkdir(parents=True)
(source / "a.pdf").write_bytes(b"%PDF")
(source / "2026" / "b.pdf").write_bytes(b"%PDF")
(source / "note.txt").write_text("skip me", encoding="utf-8")
target = tmp_path / "archive"
moved = archive_invoices(source, target)
assert [p.name for p in moved] == ["b.pdf", "a.pdf"]
assert sorted(p.name for p in target.iterdir()) == ["a.pdf", "b.pdf"]
assert (source / "note.txt").exists()
assert not (source / "a.pdf").exists()
pytest -q test_archive.py
. [100%]
1 passed in 0.02s
moved 中的顺序是 b.pdf 在 a.pdf 之前。原因是:sorted 比较 Path 对象,而 inbox/2026/b.pdf 在 inbox/a.pdf 之前,因为字符 2 小于 a。当你从比较文件名字符串转换为比较 Path 时,容易忽略这个细节。
10. 常见错误
将 Path 与字符串相加。 Path("data") + "/x.txt" 报告 TypeError。你应该使用 / 而不是 +。如果真的需要连接到文件名,可以使用 p.with_name(p.name + "_old")。
右侧是绝对路径。 结果可能会让你惊讶:
from pathlib import Path
print(Path("/srv/uploads") / "/etc/passwd") # /etc/passwd
右侧的绝对路径会替换左侧的全部内容。如果文件名来自用户,你必须在使用前进行检查:
base = Path("/srv/uploads").resolve()
candidate = (base / user_input).resolve()
if not candidate.is_relative_to(base):
raise ValueError("路径无效")
with_suffix 缺少点号。 p.with_suffix("pdf") 报告 ValueError。你必须写成 ".pdf"。
忘记 glob 返回生成器。 你只能遍历一次。如果需要重复使用或调用 len(),可以用 list(...) 包装。结果的顺序也不能保证,所以当顺序重要时需要使用 sorted。
隐藏文件和 stem。 对于 Path(".env"),suffix 是空字符串,stem 是 ".env"。不要假设每个文件都有扩展名。
旧库只接受 str。 大多数标准 API 从 Python 3.6 开始接受 os.PathLike。如果遇到旧库,可以使用 str(p) 或 os.fspath(p)。
注意:
pathlib不能完全替代os和shutil。要复制文件,你仍然需要使用shutil.copy2(Python 3.14 才新增Path.copy)。环境变量仍然通过os.environ读取。复杂的权限仍然需要os.chmod或os.access。
何时不需要更改? 对于处理数百万条路径字符串但不涉及磁盘的循环,创建 Path 对象比纯字符串操作要慢。这种情况很少见,你应该在优化前进行测试。对于运行稳定的旧代码,不需要批量修改。在修改每个模块时逐步迁移。
11. 总结
pathlib 将分散在 os、os.path 和 glob 中的操作整合到一个对象中。代码更简洁,更易读,且字符串拼接错误更少。你已经看到如何使用 / 拼接路径,使用 name、stem、suffix、parent 拆解组件,使用 read_text 和 write_text 读写文件,使用 iterdir、glob、rglob、walk 遍历目录,以及如何用 pytest 的 tmp_path 进行测试。
你应该记住三点。始终传入 encoding。小心与来自外部输入的绝对路径拼接。对于复制和删除目录树等操作仍然使用 shutil。对于新代码,应该默认使用 Path,只有在有具体原因时才回到 os.path。