各位有没有写过这样的代码?
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.exists 加 os.path.isfile,方法散落在 os 和 os.path 两个模块里,还要再加一个 open 内建函数。光是数函数名就数得我水哥头都晕。
更要命的是,路径在这套写法里只是个普通字符串。字符串就是字符串,它不知道自己代表的是文件还是目录,更不可能自己跑去判断「我存在吗」、「我是什么后缀」。所有这些操作都得拿着字符串去喂给一堆函数,函数再吐回字符串,字符串再传给下一个函数 …… 整个过程像极了车间里搬砖头的流水线。
那么有没有一种写法,让路径自己「活」起来呢?让 Path('data/foo.txt') 这种东西本身就知道自己是不是文件、能不能被读取、有什么后缀名?
有的,就是今天要聊的 pathlib。这是 Python 3.4 加进来的标准库,专门用来收拾上面这种乱糟糟的路径代码。从 Python 3.6 开始,标准库里大部分接受路径字符串的函数也都能直接吃 Path 对象,所以基本可以无痛切过来。
写到现在 Python 都 3.13 了,各位童鞋如果还在 os.path.join 一条道走到黑,那真的是亏大了。本文就跟着水哥一起,把 pathlib 这套现代 API 从头到尾捋一遍。
先来认识一下主角:
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.basename、os.path.splitext 这些函数里,记起来挺麻烦。
Path 把这些都做成属性了,一个个看。
from pathlib import Path
p = Path('/tmp/demo/foo.txt')
print(p.name)输出:
foo.txt
相当于以前的 os.path.basename(...)。
from pathlib import Path
p = Path('/tmp/demo/foo.txt')
print(p.stem)输出:
foo
这个就是文件名去掉扩展名的部分,特别适合用来生成「同名但换后缀」的新文件,比如 foo.txt 转 foo.json。
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 给一个列表。
from pathlib import Path
p = Path('/tmp/demo/sub/foo.txt')
print(p.parent)输出:
/tmp/demo/sub
相当于 os.path.dirname(...),但读起来自然多了。
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 循环。
锚点是路径的「根」部分。
from pathlib import Path
p = Path('/tmp/demo/foo.txt')
print(p.anchor)输出:
/
在 Linux / macOS 上一般就是 /,在 Windows 上可能是 C:\ 这种盘符。这个属性平时用得不多,但跨平台代码里偶尔会派上用场。
各位看一个综合例子,把所有属性串起来感受一下:
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
如果想知道文件大小、修改时间这些更详细的信息,用 .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 给了三个利器。
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)} 个文件').glob 用的是 shell 那种通配符,星号 * 代表任意字符(不包括路径分隔符),问号 ? 代表单个字符。
from pathlib import Path
root = Path('/tmp')
for txt in root.glob('*.txt'):
print(txt)这段代码会列出 /tmp 目录下所有以 .txt 结尾的文件。但 .txt 在子目录里的不会被找到。
.rglob 是 .glob('**/' + pattern) 的简写,意思是「递归地在所有子目录里找」。
from pathlib import Path
root = Path('/tmp')
md_files = list(root.rglob('*.md'))
print(f'找到 {len(md_files)} 个 markdown 文件')这段代码会把 /tmp 目录下、所有子孙目录里的 .md 文件全找出来。
来个综合例子,找出某目录下所有 .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.walk 加 endswith('.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 的「肌肉记忆」,但说实话,要读取一个文件的全部内容,还要写这么一行 + 缩进一行,是不是有点 …… 啰嗦?
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()。
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 写入文件,如果文件已经存在,会被覆盖(注意,是覆盖不是追加)。返回值是写入的字符数。
文本之外,二进制也有对应方法:
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。
那是不是有了 read_text / write_text,open 就没用了?
也不是。当你要做下面这些事情的时候,还得用传统的 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 都准备好了。
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,但 a 和 b 都还没有:
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),写脚本的时候几乎闭着眼睛就能用。
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')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('/') 把家给端了。
实际项目里,路径有「相对」和「绝对」两种形态,经常需要互相转换。
from pathlib import Path
p = Path('foo.txt')
print(p)
print(p.absolute())输出大概长这样(取决于你当前在哪):
foo.txt
/Users/two_water/projects/demo/foo.txt
注意 .absolute() 不要求文件真的存在,它只是把相对路径拼到当前工作目录前面,得到一个绝对形式。
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()。
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。
各位写脚本经常会接一个用户输入的路径,比如配置文件里写着 ~/.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(...),处理用户输入的路径基本必备。
from pathlib import Path
raw = '~/Desktop/../Desktop/foo.txt'
p = Path(raw).expanduser().resolve()
print(p)先展 ~,再 resolve 化简和取绝对路径,最后得到一个干净规整的绝对路径。这种写法在「读用户配置」这种场景非常顺手。
各位有没有这种需求?拿到一个 foo.txt,想生成同目录下的 foo.json、或者把 report_v1.md 改成 report_v2.md。
老写法基本得字符串切片加 os.path.join,写起来贼丑。pathlib 给了三个非常贴心的方法。
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
.with_name(...) 会把最后那一段(包括后缀)整个换掉:
from pathlib import Path
p = Path('/tmp/demo/foo.txt')
print(p.with_name('bar.md'))输出:
/tmp/demo/bar.md
这个是 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 标准库的设计者很贴心是不是?
来一个把 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
链式调用,一气呵成。
各位有没有遇到过这种情况:在 macOS 上写好的脚本,扔到同事 Windows 上跑就崩了?很多时候就栽在路径分隔符上——macOS / Linux 用 /,Windows 用 \。
pathlib 把这件事处理得相当优雅。它有两条继承线:
- 「具体路径」:
Path(自动选)、PosixPath、WindowsPath——能真的去访问文件系统 - 「纯路径」:
PurePath、PurePosixPath、PureWindowsPath——只做字符串层面的路径操作,不碰文件系统
平时各位 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 就够了。
为了让童鞋们对从老 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 |
各位写代码的时候忘了,回来翻一翻就好。
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() 这些「真做事」的方法。
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) 方法(要求两边都真的存在)。
from pathlib import Path
p = Path('/tmp/Makefile')
print(repr(p.suffix))
print(repr(p.stem))输出:
''
'Makefile'
各位写「按后缀过滤」的代码时要小心:.suffix == '' 对 Makefile、README 这种不带扩展名的文件成立,可别误伤。
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 都建议加上这个参数。
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文件的总行数。
老写法可能会这样:
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 totalos.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}')我们走读一下:
root.rglob('*.py')递归找出所有.py文件,省掉os.walk那一坨py.is_file()直接挂在对象上,看着就舒服py.read_text(...)一行把文件全读出来,不用with open- 数行数用
text.count('\n'),再补一下「最后一行没换行符」的情况
整个核心循环就 5 行,跟老写法对比,是不是清爽了一大截?
那么再问一个问题:如果某些 .py 文件不是 UTF-8 编码会怎样?read_text 会抛 UnicodeDecodeError。我们这里加了 errors='ignore',遇到不能解码的字节就跳过,保证统计不中断。这种小技巧在写工具脚本的时候特别有用。
各位善于思考的童鞋可以再优化一下:
- 排除
.venv、__pycache__这种目录 - 区分「空行」和「非空行」分别统计
- 加个
--ext参数,让它支持任意扩展名
这就当作课后作业,自己玩起来吧。
各位看到这里可能会担心:项目里有些「祖传代码」用的是字符串路径,或者用了某个第三方库,它的接口要求传字符串而不是 Path,怎么办?
其实根本不用担心。Path 跟字符串之间互相转换非常顺。
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,都可以这么传。
反过来更简单,前面我们已经用过无数次了:
from pathlib import Path
s = '/tmp/foo.txt'
p = Path(s)
print(p)从 Python 3.6 开始,os.PathLike 这个协议让标准库里几乎所有接受路径的函数(包括 open、os.listdir、shutil.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.path、os、open、shutil 的一部分)整合到了一个对象上。p.exists()、p.read_text()、p.mkdir(parents=True, exist_ok=True),全部从 p 这个对象出发,不用再到处 import。
各位童鞋以后写新代码,就别再 os.path.join 一条道走到黑啦,直接 from pathlib import Path 是真香。