跳转至

pathlib 路径处理

各位有没有写过这样的代码?

import os

base_dir = os.path.dirname(os.path.abspath(__file__))
data_path = os.path.join(base_dir, 'data', 'sub', 'foo.txt')

if os.path.exists(data_path) and os.path.isfile(data_path):
    with open(data_path, 'r', encoding='utf-8') as f:
        content = f.read()
    print(content)

是不是看着头大?一个 os.path.dirname 套一个 os.path.abspath,再嵌一个 os.path.join,路径就这么一层一层包出来了。要打开还得 with open(...) as f:,要判断还得 os.path.existsos.path.isfile,方法散落在 osos.path 两个模块里,还要再加一个 open 内建函数。光是数函数名就数得我水哥头都晕。

更要命的是,路径在这套写法里只是个普通字符串。字符串就是字符串,它不知道自己代表的是文件还是目录,更不可能自己跑去判断「我存在吗」、「我是什么后缀」。所有这些操作都得拿着字符串去喂给一堆函数,函数再吐回字符串,字符串再传给下一个函数 …… 整个过程像极了车间里搬砖头的流水线。

那么有没有一种写法,让路径自己「活」起来呢?让 Path('data/foo.txt') 这种东西本身就知道自己是不是文件、能不能被读取、有什么后缀名?

有的,就是今天要聊的 pathlib。这是 Python 3.4 加进来的标准库,专门用来收拾上面这种乱糟糟的路径代码。从 Python 3.6 开始,标准库里大部分接受路径字符串的函数也都能直接吃 Path 对象,所以基本可以无痛切过来。

写到现在 Python 都 3.13 了,各位童鞋如果还在 os.path.join 一条道走到黑,那真的是亏大了。本文就跟着水哥一起,把 pathlib 这套现代 API 从头到尾捋一遍。

一、第一个 Path

先来认识一下主角:

from pathlib import Path

p = Path('data/foo.txt')
print(p)
print(type(p))

输出可能长这样(macOS / Linux 上):

data/foo.txt
<class 'pathlib.PosixPath'>

是不是发现什么不对?我们明明是 Path('data/foo.txt'),怎么打印出来类型是 PosixPath

这是因为 Path 是个聪明家伙,它会根据你当前的操作系统,自动给你返回对应平台的子类:

  • 在 macOS / Linux 上,返回 PosixPath
  • 在 Windows 上,返回 WindowsPath

各位平时基本不用关心这个,直接用 Path 就完事了,需要跨平台细节的时候再说。

除了 Path('字符串') 这种基础用法,还有两个非常常用的「快捷入口」:

from pathlib import Path

print(Path.home())
print(Path.cwd())

输出:

/Users/two_water
/Users/two_water/projects/demo

Path.home() 是当前用户的家目录,Path.cwd() 是「当前工作目录」(current working directory)。这两个相当于以前的 os.path.expanduser('~')os.getcwd(),不过显然新写法看着更清爽,是不是?

二、用 / 来拼接路径

路径拼接是最常见的操作,没有之一。看看老写法:

import os

base = '/tmp'
data_path = os.path.join(base, 'demo', 'sub', 'foo.txt')
print(data_path)

输出:

/tmp/demo/sub/foo.txt

这个 os.path.join 用是好用,但写起来嘴里得念好几遍 os path join,手指都不想动了。

然后看看 pathlib 是怎么玩的:

from pathlib import Path

base = Path('/tmp')
data_path = base / 'demo' / 'sub' / 'foo.txt'
print(data_path)

输出:

/tmp/demo/sub/foo.txt

注意看:拼接路径用的是 / 这个操作符。是不是有点神奇?

为什么啊?因为 Path 重载了 __truediv__(也就是除法操作符),所以 Path('/tmp') / 'demo' 这种写法就被翻译成了路径拼接。这种设计简直是天才,因为路径在 URL、Linux 文件系统里用的本来就是 /,跟我们脑子里的语义完全一致。

来个对比表,看看是不是清爽多了:

老写法 新写法
os.path.join(a, b, c) Path(a) / b / c
os.path.join(os.path.dirname(__file__), 'data') Path(__file__).parent / 'data'
os.path.expanduser('~/Documents') Path.home() / 'Documents'

而且 / 还可以拿一个 Path 跟字符串混着用,结果都是 Path 对象:

from pathlib import Path

p1 = Path('/tmp') / 'foo'
p2 = Path('/tmp') / Path('foo')
p3 = 'foo' / Path('/tmp')

print(p1)
print(p2)
print(p3)

输出:

/tmp/foo
/tmp/foo
/tmp/foo

不管 / 左边还是右边是字符串,只要有一边是 Path,结果就还是 Path

三、常用属性,一锅端

各位写过爬虫或者文件处理代码的话,对「拿到一个路径,我要它的文件名」、「我要它的扩展名」这种需求肯定不陌生。老写法散布在 os.path.basenameos.path.splitext 这些函数里,记起来挺麻烦。

Path 把这些都做成属性了,一个个看。

3.1 .name:完整文件名(带后缀)

from pathlib import Path

p = Path('/tmp/demo/foo.txt')
print(p.name)

输出:

foo.txt

相当于以前的 os.path.basename(...)

3.2 .stem:去掉后缀的「主干」

from pathlib import Path

p = Path('/tmp/demo/foo.txt')
print(p.stem)

输出:

foo

这个就是文件名去掉扩展名的部分,特别适合用来生成「同名但换后缀」的新文件,比如 foo.txtfoo.json

3.3 .suffix:扩展名(带点)

from pathlib import Path

p = Path('/tmp/demo/foo.txt')
print(p.suffix)

输出:

.txt

注意是带点的。如果你需要不带点的,自己 [1:] 切一下就好。

那么如果是 foo.tar.gz 这种多后缀的呢?

from pathlib import Path

p = Path('archive.tar.gz')
print(p.suffix)
print(p.suffixes)

输出:

.gz
.tar.gz

看到了吗?.suffix 只给最后一个,.suffixes 给一个列表。

3.4 .parent:父目录

from pathlib import Path

p = Path('/tmp/demo/sub/foo.txt')
print(p.parent)

输出:

/tmp/demo/sub

相当于 os.path.dirname(...),但读起来自然多了。

3.5 .parents:所有祖先目录

from pathlib import Path

p = Path('/tmp/demo/sub/foo.txt')
for ancestor in p.parents:
    print(ancestor)

输出:

/tmp/demo/sub
/tmp/demo
/tmp
/

.parents 返回的是一个序列,从最近的父目录开始一路向上找祖先。可以用 p.parents[0]p.parents[1] 这种下标访问,也可以 for 循环。

3.6 .anchor:锚点

锚点是路径的「根」部分。

from pathlib import Path

p = Path('/tmp/demo/foo.txt')
print(p.anchor)

输出:

/

在 Linux / macOS 上一般就是 /,在 Windows 上可能是 C:\ 这种盘符。这个属性平时用得不多,但跨平台代码里偶尔会派上用场。

3.7 综合演示

各位看一个综合例子,把所有属性串起来感受一下:

from pathlib import Path

p = Path('/Users/two_water/projects/demo/main.py')

print('name    :', p.name)
print('stem    :', p.stem)
print('suffix  :', p.suffix)
print('parent  :', p.parent)
print('anchor  :', p.anchor)
print('parts   :', p.parts)

输出:

name    : main.py
stem    : main
suffix  : .py
parent  : /Users/two_water/projects/demo
anchor  : /
parts   : ('/', 'Users', 'two_water', 'projects', 'demo', 'main.py')

最后多送的一个 .parts,把整个路径切成元组,方便逐段处理。是不是发现路径在 pathlib 里彻底不是字符串了,它是一个有属性、有方法的「对象」?

四、判断和信息

光能拼路径还不够,我们经常要判断「这文件存不存在啊」、「是文件还是目录啊」。

老写法:

import os

p = '/tmp'
if os.path.exists(p):
    if os.path.isdir(p):
        print('是目录')
    elif os.path.isfile(p):
        print('是文件')

新写法:

from pathlib import Path

p = Path('/tmp')
if p.exists():
    if p.is_dir():
        print('是目录')
    elif p.is_file():
        print('是文件')

这两段长度差不多,但意思就完全不一样了。新写法里,p 自己知道「我存不存在」、「我是文件还是目录」,方法直接挂在对象上。老写法里 p 只是个字符串,所有判断都得拿到 os.path 模块里去查。

下面把常用的判断方法列一下:

方法 含义
.exists() 路径是否存在
.is_file() 是不是普通文件
.is_dir() 是不是目录
.is_symlink() 是不是软链接
.is_absolute() 是不是绝对路径

注意 .is_file().is_dir() 都隐含了「存在」这个条件,所以一般不用先调 .exists()。除非你想区分「不存在」和「存在但不是文件」这两种情况。

来个真实例子:

from pathlib import Path

p = Path('/tmp')
print('exists  :', p.exists())
print('is_dir  :', p.is_dir())
print('is_file :', p.is_file())

/tmp 在 macOS / Linux 上是一定存在的目录,所以输出会是:

exists  : True
is_dir  : True
is_file : False

4.1 拿到详细信息:.stat()

如果想知道文件大小、修改时间这些更详细的信息,用 .stat()

from pathlib import Path
import datetime

p = Path('/tmp')
info = p.stat()
print('size      :', info.st_size, 'bytes')
print('mtime ts  :', info.st_mtime)
print('mtime str :', datetime.datetime.fromtimestamp(info.st_mtime))

.stat() 返回的是一个 os.stat_result 对象,常用字段有:

  • .st_size:文件大小(字节)
  • .st_mtime:最后修改时间(Unix 时间戳)
  • .st_ctime:创建时间(具体含义因系统而异)
  • .st_mode:权限位

时间戳是浮点数,要变成可读时间,可以用 datetime.datetime.fromtimestamp(...) 转一下。

五、遍历目录

各位有没有写过「找出某个文件夹下所有 .py 文件」这种代码?老写法基本得 os.walk,写起来有点费劲。pathlib 给了三个利器。

5.1 .iterdir():只列当前目录下一层

from pathlib import Path

root = Path('/tmp')
for child in root.iterdir():
    print(child)

/tmp 下一层的所有内容(文件 + 目录)都会被列出来。注意 .iterdir() 不递归,只看当前目录这一层。

如果只想要文件,加个判断:

from pathlib import Path

root = Path('/tmp')
files = [c for c in root.iterdir() if c.is_file()]
print(f'共有 {len(files)} 个文件')

5.2 .glob(pattern):匹配模式(仅当前目录)

.glob 用的是 shell 那种通配符,星号 * 代表任意字符(不包括路径分隔符),问号 ? 代表单个字符。

from pathlib import Path

root = Path('/tmp')
for txt in root.glob('*.txt'):
    print(txt)

这段代码会列出 /tmp 目录下所有以 .txt 结尾的文件。但 .txt 在子目录里的不会被找到。

5.3 .rglob(pattern):递归匹配

.rglob.glob('**/' + pattern) 的简写,意思是「递归地在所有子目录里找」。

from pathlib import Path

root = Path('/tmp')
md_files = list(root.rglob('*.md'))
print(f'找到 {len(md_files)} 个 markdown 文件')

这段代码会把 /tmp 目录下、所有子孙目录里的 .md 文件全找出来。

5.4 实战:找出某目录下所有 Python 文件

来个综合例子,找出某目录下所有 .py 文件并统计:

from pathlib import Path

root = Path.cwd()
py_files = list(root.rglob('*.py'))

print(f'在 {root} 下找到 {len(py_files)} 个 .py 文件')
for f in py_files[:5]:
    print(' -', f.relative_to(root))

Path.cwd() 是当前工作目录,.rglob('*.py') 递归找所有 .py.relative_to(root) 是把绝对路径转成相对路径,看起来更清爽。

各位自己跑一下,应该会看到当前项目里的 .py 文件列表。这种写法是不是比 os.walkendswith('.py') 那一套清爽不止一截?

六、读写文件

这是 pathlib 最让我感动的功能之一。

老写法读文件:

with open('/tmp/foo.txt', 'r', encoding='utf-8') as f:
    content = f.read()
print(content)

老写法写文件:

with open('/tmp/foo.txt', 'w', encoding='utf-8') as f:
    f.write('hello two_water')

with open(...) as f: 这一坨已经成了 Python 的「肌肉记忆」,但说实话,要读取一个文件的全部内容,还要写这么一行 + 缩进一行,是不是有点 …… 啰嗦?

6.1 .read_text() 一行读完

from pathlib import Path

p = Path('/tmp/two_water_demo.txt')
p.write_text('hello two_water', encoding='utf-8')

content = p.read_text(encoding='utf-8')
print(content)

输出:

hello two_water

是不是清爽?.read_text() 直接把整个文件读成字符串,不用 with、不用 open、不用 f.read()

6.2 .write_text() 一行写完

from pathlib import Path

p = Path('/tmp/two_water_demo.txt')
p.write_text('两点水的打卡记录\n', encoding='utf-8')
print(p.read_text(encoding='utf-8'))

.write_text(s) 会把字符串 s 写入文件,如果文件已经存在,会被覆盖(注意,是覆盖不是追加)。返回值是写入的字符数。

6.3 二进制读写:.read_bytes().write_bytes()

文本之外,二进制也有对应方法:

from pathlib import Path

p = Path('/tmp/two_water_bin.dat')
p.write_bytes(b'\x00\x01\x02two_water')

data = p.read_bytes()
print(data)
print(len(data), 'bytes')

输出:

b'\x00\x01\x02two_water'
12 bytes

.read_bytes().write_bytes() 处理的是 bytes 对象,不需要也不能传 encoding

6.4 什么时候还需要 open()

那是不是有了 read_text / write_textopen 就没用了?

也不是。当你要做下面这些事情的时候,还得用传统的 with open(...) as f:

  • 读超大文件,需要逐行读,避免一次读到内存里
  • 需要追加模式('a'
  • 需要在写入过程中做复杂逻辑(比如边读边算边写)

但好消息是,Path 对象也提供了 .open() 方法,可以直接用:

from pathlib import Path

p = Path('/tmp/two_water_lines.txt')
p.write_text('line1\nline2\nline3\n', encoding='utf-8')

with p.open('r', encoding='utf-8') as f:
    for line in f:
        print(line.rstrip())

输出:

line1
line2
line3

p.open(...) 跟内建 open(p, ...) 完全等价,但更顺手——所有文件操作都从 p 这个对象出发。

七、创建和删除

创建目录、创建空文件、删除文件、删除目录,这都是经常要做的事。pathlib 都准备好了。

7.1 .mkdir() 创建目录

from pathlib import Path

p = Path('/tmp/two_water_demo_dir')
p.mkdir(exist_ok=True)
print(p.exists(), p.is_dir())

输出:

True True

exist_ok=True 这个参数特别有用:如果目录已经存在,不会报错;如果设成 False(默认),目录已存在就会抛 FileExistsError

那如果父目录都不存在呢?比如要建 /tmp/a/b/c,但 ab 都还没有:

from pathlib import Path

p = Path('/tmp/two_water_a/b/c')
p.mkdir(parents=True, exist_ok=True)
print(p.exists())

parents=True 就是「如果父目录不存在,一路递归创建」,等价于 shell 里的 mkdir -p

记住这个组合拳——mkdir(parents=True, exist_ok=True),写脚本的时候几乎闭着眼睛就能用。

7.2 .touch() 创建空文件

from pathlib import Path

p = Path('/tmp/two_water_demo.empty')
p.touch(exist_ok=True)
print(p.exists(), p.is_file(), p.stat().st_size)

输出大概是:

True True 0

.touch() 类似 shell 里的 touch 命令,文件不存在就创建一个空文件,存在就更新它的修改时间。

from pathlib import Path

p = Path('/tmp/two_water_demo.empty')
if p.exists():
    p.unlink()
print('after unlink:', p.exists())

输出:

after unlink: False

.unlink() 删的是「单个文件或软链接」。如果文件不存在会报 FileNotFoundError,可以用 missing_ok=True(Python 3.8+)来避免:

from pathlib import Path

p = Path('/tmp/two_water_does_not_exist.x')
p.unlink(missing_ok=True)
print('done')

7.4 .rmdir() 删除空目录

from pathlib import Path

p = Path('/tmp/two_water_demo_dir')
p.mkdir(exist_ok=True)
p.rmdir()
print('after rmdir:', p.exists())

注意:.rmdir() 只能删「空目录」。要是目录里还有内容,会抛 OSError

那要删非空目录怎么办?pathlib 自己没有提供,得靠标准库 shutil

import shutil
from pathlib import Path

p = Path('/tmp/two_water_demo_full')
p.mkdir(exist_ok=True)
(p / 'child.txt').write_text('hi', encoding='utf-8')

shutil.rmtree(p)
print('after rmtree:', p.exists())

输出:

after rmtree: False

shutil.rmtree 是「连内容带目录一起删」,相当于 rm -rf。各位用这个的时候千万看清路径,别一不小心 rmtree('/') 把家给端了。

八、路径转换

实际项目里,路径有「相对」和「绝对」两种形态,经常需要互相转换。

8.1 .absolute():转成绝对路径(不解析符号链接)

from pathlib import Path

p = Path('foo.txt')
print(p)
print(p.absolute())

输出大概长这样(取决于你当前在哪):

foo.txt
/Users/two_water/projects/demo/foo.txt

注意 .absolute() 不要求文件真的存在,它只是把相对路径拼到当前工作目录前面,得到一个绝对形式。

8.2 .resolve():转成绝对路径(并解析符号链接、.. 等)

from pathlib import Path

p = Path('foo/../bar/./baz.txt')
print(p)
print(p.resolve())

输出(在 /tmp 下跑):

foo/../bar/./baz.txt
/tmp/bar/baz.txt

看到了吧?.resolve() 会把 ... 这种相对引用全部「化简」掉,得到一个干净的绝对路径。它还会跟着符号链接走到真实位置。

.absolute().resolve() 啥时候用哪个呢?记住一条粗糙但够用的规则:默认就用 .resolve()。它更彻底,结果更干净。只有你明确不想跟随软链、不想化简 .. 的时候,才用 .absolute()

8.3 .relative_to(other):算相对路径

from pathlib import Path

base = Path('/tmp')
file = Path('/tmp/demo/sub/foo.txt')

print(file.relative_to(base))

输出:

demo/sub/foo.txt

这个特别适合用来打日志、做展示,比如打印一个项目里所有文件的相对路径,看着比绝对路径舒服一万倍。

注意 .relative_to(other) 要求当前路径必须是 other 的子孙,否则会抛 ValueError

8.4 .expanduser():把 ~ 展开成家目录

各位写脚本经常会接一个用户输入的路径,比如配置文件里写着 ~/.config/myapp/conf.toml。这个 ~ 是 shell 里的「家目录」简写,但 Python 不会自动展开它,得自己来:

from pathlib import Path

p = Path('~/Documents/foo.txt')
print(p)
print(p.expanduser())

输出(具体家目录因人而异):

~/Documents/foo.txt
/Users/two_water/Documents/foo.txt

这个 .expanduser() 相当于以前的 os.path.expanduser(...),处理用户输入的路径基本必备。

8.5 一个常见组合拳:expanduser().resolve()

from pathlib import Path

raw = '~/Desktop/../Desktop/foo.txt'
p = Path(raw).expanduser().resolve()
print(p)

先展 ~,再 resolve 化简和取绝对路径,最后得到一个干净规整的绝对路径。这种写法在「读用户配置」这种场景非常顺手。

九、改个名字:with_name / with_stem / with_suffix

各位有没有这种需求?拿到一个 foo.txt,想生成同目录下的 foo.json、或者把 report_v1.md 改成 report_v2.md

老写法基本得字符串切片加 os.path.join,写起来贼丑。pathlib 给了三个非常贴心的方法。

9.1 .with_suffix(new_suffix) 换后缀

from pathlib import Path

p = Path('/tmp/foo.txt')
print(p.with_suffix('.json'))
print(p.with_suffix(''))

输出:

/tmp/foo.json
/tmp/foo

注意 new_suffix 必须以 . 开头(或者是空字符串,表示去掉后缀)。这个方法非常适合做「同名换格式」的需求,比如批量把 .md 转成 .html

from pathlib import Path

src = Path('/tmp/two_water_doc.md')
src.write_text('# 标题', encoding='utf-8')

dst = src.with_suffix('.html')
print('源文件:', src)
print('目标:', dst)

输出:

源文件: /tmp/two_water_doc.md
目标: /tmp/two_water_doc.html

9.2 .with_name(new_name) 换整个文件名

.with_name(...) 会把最后那一段(包括后缀)整个换掉:

from pathlib import Path

p = Path('/tmp/demo/foo.txt')
print(p.with_name('bar.md'))

输出:

/tmp/demo/bar.md

9.3 .with_stem(new_stem) 换主干(保留后缀)

这个是 Python 3.9 才加的,专门用来「保留后缀,只换主干」:

from pathlib import Path

p = Path('/tmp/demo/foo.txt')
print(p.with_stem('bar'))

输出:

/tmp/demo/bar.txt

后缀 .txt 保留不变,主干 foo 换成 bar

各位想想,要是没有 .with_stem,干这件事得手动 p.with_name(new_stem + p.suffix),多出一步拼接。Python 标准库的设计者很贴心是不是?

9.4 综合演示

来一个把 report_v1.md 改成 report_v2.json 的例子:

from pathlib import Path

p = Path('/tmp/report_v1.md')
new = p.with_stem('report_v2').with_suffix('.json')
print(new)

输出:

/tmp/report_v2.json

链式调用,一气呵成。

十、跨平台:PurePath 系列

各位有没有遇到过这种情况:在 macOS 上写好的脚本,扔到同事 Windows 上跑就崩了?很多时候就栽在路径分隔符上——macOS / Linux 用 /,Windows 用 \

pathlib 把这件事处理得相当优雅。它有两条继承线:

  • 「具体路径」:Path(自动选)、PosixPathWindowsPath——能真的去访问文件系统
  • 「纯路径」:PurePathPurePosixPathPureWindowsPath——只做字符串层面的路径操作,不碰文件系统

平时各位 99% 的时间都用 Path 就够了。但偶尔,比如你在 Linux 上要解析一段 Windows 风格的路径字符串,就会需要 PureWindowsPath

from pathlib import PureWindowsPath, PurePosixPath

p = PureWindowsPath(r'C:\Users\two_water\foo.txt')
print(p.name)
print(p.parent)

q = PurePosixPath('/home/two_water/foo.txt')
print(q.name)
print(q.parent)

输出:

foo.txt
C:\Users\two_water
foo.txt
/home/two_water

可以看到,PureWindowsPath 哪怕在 macOS 上跑,也按 Windows 的方式解析路径;PurePosixPath 反之。这两个东西不能调用 .exists().read_text() 这种「需要真访问文件系统」的方法,但用来做「路径字符串解析」绰绰有余。

跨平台代码这块就先点到为止,绝大多数童鞋日常用 Path 就够了。

十一、老写法对照表 + 常见踩坑

11.1 一份对照表,方便各位收藏

为了让童鞋们对从老 API 切到 pathlib 心里有数,这里把最常见的迁移做成一份表:

操作 老写法(os / os.path / open 新写法(pathlib
当前工作目录 os.getcwd() Path.cwd()
用户家目录 os.path.expanduser('~') Path.home()
路径拼接 os.path.join(a, b, c) Path(a) / b / c
文件名 os.path.basename(p) Path(p).name
主干(去后缀) os.path.splitext(name)[0] Path(p).stem
扩展名 os.path.splitext(name)[1] Path(p).suffix
父目录 os.path.dirname(p) Path(p).parent
是否存在 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.abspath(p) Path(p).resolve()
相对路径 os.path.relpath(p, base) Path(p).relative_to(base)
创建目录 os.makedirs(p, exist_ok=True) Path(p).mkdir(parents=True, exist_ok=True)
删除文件 os.remove(p) Path(p).unlink()
删除空目录 os.rmdir(p) Path(p).rmdir()
列目录 os.listdir(p) list(Path(p).iterdir())
通配匹配 glob.glob(pattern) Path(...).glob(pattern)
递归通配 glob.glob(pattern, recursive=True) Path(...).rglob(pattern)
读文本 with open(p) as f: content = f.read() Path(p).read_text(encoding='utf-8')
写文本 with open(p, 'w') as f: f.write(s) Path(p).write_text(s, encoding='utf-8')
文件大小 os.path.getsize(p) Path(p).stat().st_size

各位写代码的时候忘了,回来翻一翻就好。

11.2 常见踩坑

坑一:忘了 Path 不会自动创建文件

from pathlib import Path

p = Path('/tmp/two_water_not_exist.txt')
print(p)
print(p.exists())

输出:

/tmp/two_water_not_exist.txt
False

是不是发现什么了?光 Path('xxx') 不会在磁盘上真的搞出一个文件来,它只是创建一个「路径对象」。这个对象代表的文件可能存在,也可能不存在。要真把文件搞出来,得 .touch().write_text().mkdir() 这些「真做事」的方法。

坑二:Path('foo') == Path('./foo')

from pathlib import Path

a = Path('foo')
b = Path('./foo')
print(a == b)
print(a.resolve() == b.resolve())

输出:

False
True

注意了,Path('foo')Path('./foo') 在「字符串层面」是不相等的,但 .resolve() 之后就一样了。如果各位要比较两个路径是否「指向同一个东西」,建议先 .resolve() 再比,或者用更专业的 .samefile(other) 方法(要求两边都真的存在)。

坑三:.suffix 对没扩展名的文件返回空字符串

from pathlib import Path

p = Path('/tmp/Makefile')
print(repr(p.suffix))
print(repr(p.stem))

输出:

''
'Makefile'

各位写「按后缀过滤」的代码时要小心:.suffix == ''MakefileREADME 这种不带扩展名的文件成立,可别误伤。

坑四:mkdir 不带 exist_ok 会炸

from pathlib import Path

p = Path('/tmp/two_water_collide')
p.mkdir(exist_ok=True)
p.mkdir(exist_ok=True)
print('两次都没炸:', p.exists())

输出:

两次都没炸: True

如果不加 exist_ok=True,第二次 mkdir 就会抛 FileExistsError。写脚本的时候,几乎所有 mkdir 都建议加上这个参数。

坑五:relative_to 不能跨越根

from pathlib import Path

a = Path('/tmp/foo')
b = Path('/var/log')

try:
    print(a.relative_to(b))
except ValueError as e:
    print('炸了:', e)

输出:

炸了: '/tmp/foo' is not in the subpath of '/var/log' OR one path is relative and the other is absolute.

这种情况下没办法用 relative_to,只能借助 os.path.relpath,或者 Python 3.12+ 的 walk_up=True 参数:

from pathlib import Path

a = Path('/tmp/foo')
b = Path('/var/log')

print(a.relative_to(b, walk_up=True))

输出大概是:

../../tmp/foo

walk_up=True 是 3.12 才加的,会允许结果里包含 ..

十二、小实战:递归统计某目录下所有 .py 文件的总行数

讲了这么多,我们用一个小函数把上面学的东西串起来。需求是这样的:

给定一个目录,递归统计这个目录下所有 .py 文件的总行数。

老写法可能会这样:

import os

def count_py_lines_old(root):
    total = 0
    for dirpath, dirnames, filenames in os.walk(root):
        for name in filenames:
            if name.endswith('.py'):
                full = os.path.join(dirpath, name)
                with open(full, 'r', encoding='utf-8') as f:
                    total += sum(1 for _ in f)
    return total

os.walk 嵌两层 for,再 os.path.join,再 with open,活活七八行才把核心逻辑写完。

pathlib 写起来是啥样?

from pathlib import Path


def count_py_lines(root: Path) -> int:
    """递归统计 root 目录下所有 .py 文件的总行数。"""
    total = 0
    for py in root.rglob('*.py'):
        if not py.is_file():
            continue
        text = py.read_text(encoding='utf-8', errors='ignore')
        total += text.count('\n') + (0 if text.endswith('\n') or not text else 1)
    return total


if __name__ == '__main__':
    n = count_py_lines(Path.cwd())
    print(f'当前目录下 .py 总行数:{n}')

我们走读一下:

  1. root.rglob('*.py') 递归找出所有 .py 文件,省掉 os.walk 那一坨
  2. py.is_file() 直接挂在对象上,看着就舒服
  3. py.read_text(...) 一行把文件全读出来,不用 with open
  4. 数行数用 text.count('\n'),再补一下「最后一行没换行符」的情况

整个核心循环就 5 行,跟老写法对比,是不是清爽了一大截?

那么再问一个问题:如果某些 .py 文件不是 UTF-8 编码会怎样?read_text 会抛 UnicodeDecodeError。我们这里加了 errors='ignore',遇到不能解码的字节就跳过,保证统计不中断。这种小技巧在写工具脚本的时候特别有用。

各位善于思考的童鞋可以再优化一下:

  • 排除 .venv__pycache__ 这种目录
  • 区分「空行」和「非空行」分别统计
  • 加个 --ext 参数,让它支持任意扩展名

这就当作课后作业,自己玩起来吧。

十三、彩蛋:和老 API 互通

各位看到这里可能会担心:项目里有些「祖传代码」用的是字符串路径,或者用了某个第三方库,它的接口要求传字符串而不是 Path,怎么办?

其实根本不用担心。Path 跟字符串之间互相转换非常顺。

13.1 Path → 字符串:直接 str()

from pathlib import Path

p = Path('/tmp/foo.txt')
s = str(p)
print(s)
print(type(s))

输出:

/tmp/foo.txt
<class 'str'>

str(p) 就把 Path 对象转回了纯字符串。任何接受字符串路径的老 API,都可以这么传。

13.2 字符串 → PathPath(s)

反过来更简单,前面我们已经用过无数次了:

from pathlib import Path

s = '/tmp/foo.txt'
p = Path(s)
print(p)

13.3 os.PathLike:标准库的兼容协议

从 Python 3.6 开始,os.PathLike 这个协议让标准库里几乎所有接受路径的函数(包括 openos.listdirshutil.copy 等等)都能直接吃 Path 对象。也就是说:

from pathlib import Path

p = Path('/tmp/two_water_compat.txt')
p.write_text('hi', encoding='utf-8')

with open(p, 'r', encoding='utf-8') as f:
    print(f.read())

输出:

hi

是不是发现什么了?open 第一个参数我们直接传了 Path 对象,没有先 str(),照样能跑。这就是 os.PathLike 协议的功劳。

所以各位放心用 pathlib,跟标准库里的「老朋友」基本无缝兼容。

十四、小结

讲了这么多,最后总结一下 pathlib 的核心要点:

第一,路径在 pathlib 里不再是字符串,而是「对象」。它自己知道自己叫什么、在哪、是不是文件、能不能读。这种「对象自治」的设计,让代码读起来更接近自然语言。

第二,/ 操作符把路径拼接做成了一种「视觉上和路径一致」的语法。Path('/tmp') / 'foo' / 'bar.txt' 这种写法,比 os.path.join 那一长串好太多了。

第三,pathlib 把跨多个老模块的功能(os.pathosopenshutil 的一部分)整合到了一个对象上。p.exists()p.read_text()p.mkdir(parents=True, exist_ok=True),全部从 p 这个对象出发,不用再到处 import。

各位童鞋以后写新代码,就别再 os.path.join 一条道走到黑啦,直接 from pathlib import Path 是真香。