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.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 从头到尾捋一遍。
一、第一个 Path¶
先来认识一下主角:
输出可能长这样(macOS / Linux 上):
是不是发现什么不对?我们明明是 Path('data/foo.txt'),怎么打印出来类型是 PosixPath?
这是因为 Path 是个聪明家伙,它会根据你当前的操作系统,自动给你返回对应平台的子类:
- 在 macOS / Linux 上,返回
PosixPath - 在 Windows 上,返回
WindowsPath
各位平时基本不用关心这个,直接用 Path 就完事了,需要跨平台细节的时候再说。
除了 Path('字符串') 这种基础用法,还有两个非常常用的「快捷入口」:
输出:
Path.home() 是当前用户的家目录,Path.cwd() 是「当前工作目录」(current working directory)。这两个相当于以前的 os.path.expanduser('~') 和 os.getcwd(),不过显然新写法看着更清爽,是不是?
二、用 / 来拼接路径¶
路径拼接是最常见的操作,没有之一。看看老写法:
输出:
这个 os.path.join 用是好用,但写起来嘴里得念好几遍 os path join,手指都不想动了。
然后看看 pathlib 是怎么玩的:
from pathlib import Path
base = Path('/tmp')
data_path = base / 'demo' / 'sub' / 'foo.txt'
print(data_path)
输出:
注意看:拼接路径用的是 / 这个操作符。是不是有点神奇?
为什么啊?因为 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)
输出:
不管 / 左边还是右边是字符串,只要有一边是 Path,结果就还是 Path。
三、常用属性,一锅端¶
各位写过爬虫或者文件处理代码的话,对「拿到一个路径,我要它的文件名」、「我要它的扩展名」这种需求肯定不陌生。老写法散布在 os.path.basename、os.path.splitext 这些函数里,记起来挺麻烦。
Path 把这些都做成属性了,一个个看。
3.1 .name:完整文件名(带后缀)¶
输出:
相当于以前的 os.path.basename(...)。
3.2 .stem:去掉后缀的「主干」¶
输出:
这个就是文件名去掉扩展名的部分,特别适合用来生成「同名但换后缀」的新文件,比如 foo.txt 转 foo.json。
3.3 .suffix:扩展名(带点)¶
输出:
注意是带点的。如果你需要不带点的,自己 [1:] 切一下就好。
那么如果是 foo.tar.gz 这种多后缀的呢?
输出:
看到了吗?.suffix 只给最后一个,.suffixes 给一个列表。
3.4 .parent:父目录¶
输出:
相当于 os.path.dirname(...),但读起来自然多了。
3.5 .parents:所有祖先目录¶
from pathlib import Path
p = Path('/tmp/demo/sub/foo.txt')
for ancestor in p.parents:
print(ancestor)
输出:
.parents 返回的是一个序列,从最近的父目录开始一路向上找祖先。可以用 p.parents[0]、p.parents[1] 这种下标访问,也可以 for 循环。
3.6 .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 上是一定存在的目录,所以输出会是:
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():只列当前目录下一层¶
/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 那种通配符,星号 * 代表任意字符(不包括路径分隔符),问号 ? 代表单个字符。
这段代码会列出 /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.walk 加 endswith('.py') 那一套清爽不止一截?
六、读写文件¶
这是 pathlib 最让我感动的功能之一。
老写法读文件:
老写法写文件:
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)
输出:
是不是清爽?.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')
输出:
.read_bytes() 和 .write_bytes() 处理的是 bytes 对象,不需要也不能传 encoding。
6.4 什么时候还需要 open()?¶
那是不是有了 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())
输出:
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())
输出:
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),写脚本的时候几乎闭着眼睛就能用。
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)
输出大概是:
.touch() 类似 shell 里的 touch 命令,文件不存在就创建一个空文件,存在就更新它的修改时间。
7.3 .unlink() 删除文件¶
from pathlib import Path
p = Path('/tmp/two_water_demo.empty')
if p.exists():
p.unlink()
print('after unlink:', p.exists())
输出:
.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())
输出:
shutil.rmtree 是「连内容带目录一起删」,相当于 rm -rf。各位用这个的时候千万看清路径,别一不小心 rmtree('/') 把家给端了。
八、路径转换¶
实际项目里,路径有「相对」和「绝对」两种形态,经常需要互相转换。
8.1 .absolute():转成绝对路径(不解析符号链接)¶
输出大概长这样(取决于你当前在哪):
注意 .absolute() 不要求文件真的存在,它只是把相对路径拼到当前工作目录前面,得到一个绝对形式。
8.2 .resolve():转成绝对路径(并解析符号链接、.. 等)¶
输出(在 /tmp 下跑):
看到了吧?.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))
输出:
这个特别适合用来打日志、做展示,比如打印一个项目里所有文件的相对路径,看着比绝对路径舒服一万倍。
注意 .relative_to(other) 要求当前路径必须是 other 的子孙,否则会抛 ValueError。
8.4 .expanduser():把 ~ 展开成家目录¶
各位写脚本经常会接一个用户输入的路径,比如配置文件里写着 ~/.config/myapp/conf.toml。这个 ~ 是 shell 里的「家目录」简写,但 Python 不会自动展开它,得自己来:
输出(具体家目录因人而异):
这个 .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(''))
输出:
注意 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)
输出:
9.2 .with_name(new_name) 换整个文件名¶
.with_name(...) 会把最后那一段(包括后缀)整个换掉:
输出:
9.3 .with_stem(new_stem) 换主干(保留后缀)¶
这个是 Python 3.9 才加的,专门用来「保留后缀,只换主干」:
输出:
后缀 .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)
输出:
链式调用,一气呵成。
十、跨平台:PurePath 系列¶
各位有没有遇到过这种情况:在 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)
输出:
可以看到,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 不会自动创建文件¶
输出:
是不是发现什么了?光 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())
输出:
注意了,Path('foo') 和 Path('./foo') 在「字符串层面」是不相等的,但 .resolve() 之后就一样了。如果各位要比较两个路径是否「指向同一个东西」,建议先 .resolve() 再比,或者用更专业的 .samefile(other) 方法(要求两边都真的存在)。
坑三:.suffix 对没扩展名的文件返回空字符串¶
输出:
各位写「按后缀过滤」的代码时要小心:.suffix == '' 对 Makefile、README 这种不带扩展名的文件成立,可别误伤。
坑四: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())
输出:
如果不加 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))
输出大概是:
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}')
我们走读一下:
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参数,让它支持任意扩展名
这就当作课后作业,自己玩起来吧。
十三、彩蛋:和老 API 互通¶
各位看到这里可能会担心:项目里有些「祖传代码」用的是字符串路径,或者用了某个第三方库,它的接口要求传字符串而不是 Path,怎么办?
其实根本不用担心。Path 跟字符串之间互相转换非常顺。
13.1 Path → 字符串:直接 str()¶
输出:
str(p) 就把 Path 对象转回了纯字符串。任何接受字符串路径的老 API,都可以这么传。
13.2 字符串 → Path:Path(s)¶
反过来更简单,前面我们已经用过无数次了:
13.3 os.PathLike:标准库的兼容协议¶
从 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())
输出:
是不是发现什么了?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 是真香。