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。

pythonpathlibos.pathfile-iopython3