小白学 Python
AI 编程指南
© 2026 小白学 Python · 基于 walter201230/Python 教程
课程目录关于本站联系方式隐私政策GitHub

全部课程

  1. 00为什么学 Python
  2. 01Python 安装与第一个程序
  3. 02基本数据类型与变量
  4. 03List 与 Tuple
  5. 04Dict 与 Set
  6. 05条件与循环
  7. 06函数
  8. 07迭代
  9. 08面向对象
  10. 09模块
  11. 10魔法方法
  12. 11枚举类
  13. 12元类 metaclass
  14. 13线程与进程
  15. 14正则表达式
  16. 15闭包
  17. 16装饰器
  18. 17代码可读性
  19. 18pathlib:现代化路径处理
  20. 19异常处理
  21. 20dataclass
  22. 21上下文管理器(with)
  23. 22async / await
  24. 23pyproject.toml + uv
  25. 24ruff:一站式代码检查
  26. 25pytest:测试框架
  27. 26日志 logging
  28. 27打包发布到 PyPI
  29. 28学完之后

pathlib:现代化路径处理

完整讲解 · 23 段教学·配 4 道练习题·预计 95 分钟

本页是本章的通读版,可直接读完全部讲解。想动手写代码、跑判分,去 闯关模式。

教学 01 / 23

pathlib 路径处理

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

Python· 在本地运行
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 从头到尾捋一遍。

教学 02 / 23

一、第一个 Path

先来认识一下主角:

Python· 可在网页运行
在闯关页练习 →
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('字符串') 这种基础用法,还有两个非常常用的「快捷入口」:

Python· 在本地运行
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(),不过显然新写法看着更清爽,是不是?

教学 03 / 23

二、用 / 来拼接路径

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

Python· 可在网页运行
在闯关页练习 →
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 是怎么玩的:

Python· 可在网页运行
在闯关页练习 →
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 对象:

Python· 可在网页运行
在闯关页练习 →
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
foo/tmp

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

教学 04 / 23

三、常用属性,一锅端

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

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

教学 05 / 23

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

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

输出:

文本
foo.txt

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

教学 06 / 23

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

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

输出:

文本
foo

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

教学 07 / 23

3.3 .suffix:扩展名(带点)

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

输出:

文本
.txt

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

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

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

输出:

文本
.gz
.tar.gz

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

练习 1 / 4提取路径的 name / suffix / stem

请:

  1. 从 pathlib 导入 Path
  2. 创建路径对象 p = Path('/tmp/data/foo.txt')
  3. 依次 print 它的 .name、.suffix、.stem

输出应该是:

文本
foo.txt
.txt
foo

注意:print(p.name) 输出的是 foo.txt,不是 'foo.txt'——直接打印不带引号。

💡 提示(3 条)
  1. 用 from pathlib import Path 把 Path 类导入

  2. .name 是带后缀的完整文件名,.suffix 是「.xxx」带点的扩展名,.stem 是去掉后缀的主干

  3. 完整代码:

    Python
    from pathlib import Path
    p = Path('/tmp/data/foo.txt')
    print(p.name)
    print(p.suffix)
    print(p.stem)
在编辑器里写这题,跑判分、看参考答案去闯关页做这题 →
练习 2 / 4用 / 操作符拼接路径

请用 / 操作符把三段拼成一个完整路径:

  1. 从 pathlib 导入 Path
  2. 用 Path('/home/user') / 'docs' / 'report.pdf' 拼出路径,赋值给 p
  3. print(str(p))

输出应该是:

文本
/home/user/docs/report.pdf

告别 os.path.join!

💡 提示(3 条)
  1. Path 重载了 / 操作符,所以可以直接 Path('/home/user') / 'docs' / 'report.pdf'

  2. str(p) 把 Path 对象转成字符串——也可以直接 print(p),效果一样

  3. 完整代码:

    Python
    from pathlib import Path
    p = Path('/home/user') / 'docs' / 'report.pdf'
    print(str(p))
在编辑器里写这题,跑判分、看参考答案去闯关页做这题 →
教学 08 / 23

3.4 .parent:父目录

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

输出:

文本
/tmp/demo/sub

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

教学 09 / 23

3.5 .parents:所有祖先目录

Python· 可在网页运行
在闯关页练习 →
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 / 4拿父目录和祖先目录

已经给你 p = Path('/a/b/c/d.txt')。

请:

  1. print(p.parent) —— 一级父目录
  2. print(p.parents[1]) —— 二级祖先

输出应该是:

文本
/a/b/c
/a/b

注意:.parent 是属性(不带括号),.parents[i] 是序列下标——[0] 是直接父目录、[1] 是再上一层。

💡 提示(3 条)
  1. .parent 跟 .parents[0] 等价——都是直接父目录

  2. .parents[1] 是 .parent.parent,再往上一层

  3. 完整代码:

    Python
    from pathlib import Path
    p = Path('/a/b/c/d.txt')
    print(p.parent)
    print(p.parents[1])
在编辑器里写这题,跑判分、看参考答案去闯关页做这题 →
练习 4 / 4多后缀文件:.suffixes

对于 foo.tar.gz 这种「多后缀」的文件名:

  • .suffix 只给最后一个后缀(.gz)
  • .suffixes 给一个列表,包含所有后缀

请:

  1. 从 pathlib 导入 Path
  2. 创建 p = Path('test.tar.gz')
  3. print(p.suffixes)

输出应该是:

文本
['.tar', '.gz']
💡 提示(3 条)
  1. .suffix(单数)只给最后一个;.suffixes(复数)给所有

  2. 顺序是从前到后——['.tar', '.gz']

  3. 完整代码:

    Python
    from pathlib import Path
    p = Path('test.tar.gz')
    print(p.suffixes)
在编辑器里写这题,跑判分、看参考答案去闯关页做这题 →
教学 10 / 23

3.6 .anchor:锚点

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

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

输出:

文本
/

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

教学 11 / 23

3.7 综合演示

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

Python· 可在网页运行
在闯关页练习 →
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 里彻底不是字符串了,它是一个有属性、有方法的「对象」?

教学 12 / 23

四、判断和信息

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

老写法:

Python· 在本地运行
import os

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

新写法:

Python· 在本地运行
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()。除非你想区分「不存在」和「存在但不是文件」这两种情况。

来个真实例子:

Python· 在本地运行
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():

Python· 在本地运行
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(...) 转一下。

教学 13 / 23

五、遍历目录

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

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

Python· 在本地运行
from pathlib import Path

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

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

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

Python· 在本地运行
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 那种通配符,星号 * 代表任意字符(不包括路径分隔符),问号 ? 代表单个字符。

Python· 在本地运行
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) 的简写,意思是「递归地在所有子目录里找」。

Python· 在本地运行
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 文件并统计:

Python· 在本地运行
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') 那一套清爽不止一截?

教学 14 / 23

六、读写文件

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

老写法读文件:

Python· 在本地运行
with open('/tmp/foo.txt', 'r', encoding='utf-8') as f:
    content = f.read()
print(content)

老写法写文件:

Python· 在本地运行
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() 一行读完

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()。

6.2 .write_text() 一行写完

Python· 在本地运行
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()

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

Python· 在本地运行
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_text,open 就没用了?

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

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

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

Python· 在本地运行
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 这个对象出发。

教学 15 / 23

七、创建和删除

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

7.1 .mkdir() 创建目录

Python· 在本地运行
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 都还没有:

Python· 在本地运行
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() 创建空文件

Python· 在本地运行
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 命令,文件不存在就创建一个空文件,存在就更新它的修改时间。

7.3 .unlink() 删除文件

Python· 在本地运行
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+)来避免:

Python· 在本地运行
from pathlib import Path

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

7.4 .rmdir() 删除空目录

Python· 在本地运行
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:

Python· 在本地运行
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('/') 把家给端了。

教学 16 / 23

八、路径转换

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

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

Python· 在本地运行
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():转成绝对路径(并解析符号链接、.. 等)

Python· 在本地运行
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):算相对路径

Python· 可在网页运行
在闯关页练习 →
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 不会自动展开它,得自己来:

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()

Python· 在本地运行
from pathlib import Path

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

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

教学 17 / 23

九、改个名字: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) 换后缀

Python· 可在网页运行
在闯关页练习 →
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:

Python· 在本地运行
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(...) 会把最后那一段(包括后缀)整个换掉:

Python· 可在网页运行
在闯关页练习 →
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 才加的,专门用来「保留后缀,只换主干」:

Python· 可在网页运行
在闯关页练习 →
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 的例子:

Python· 可在网页运行
在闯关页练习 →
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

链式调用,一气呵成。

教学 18 / 23

十、跨平台:PurePath 系列

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

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

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

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

Python· 可在网页运行
在闯关页练习 →
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 就够了。

教学 19 / 23

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

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 不会自动创建文件
Python· 在本地运行
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')?
Python· 在本地运行
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 对没扩展名的文件返回空字符串
Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

输出:

文本
''
'Makefile'

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

坑四:mkdir 不带 exist_ok 会炸
Python· 在本地运行
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 不能跨越根
Python· 在本地运行
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 参数:

Python· 可在网页运行
在闯关页练习 →
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 才加的,会允许结果里包含 ..。

教学 20 / 23

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

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

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

老写法可能会这样:

Python· 在本地运行
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 写起来是啥样?

Python· 在本地运行
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 参数,让它支持任意扩展名

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

教学 21 / 23

十三、彩蛋:和老 API 互通

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

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

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

Python· 可在网页运行
在闯关页练习 →
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 字符串 → Path:Path(s)

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

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

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

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

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

Python· 在本地运行
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,跟标准库里的「老朋友」基本无缝兼容。

教学 22 / 23

十四、小结

讲了这么多,最后总结一下 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 是真香。

教学 23 / 23

文件写到哪里了

路径操作已经讲了很多,这次做一件能核对结果的小事。在专门的练习目录里保存两行文字,再读回来:

Python· 可在网页运行
在闯关页练习 →
from pathlib import Path

folder = Path('study-demo-records')
folder.mkdir(exist_ok=True)
path = folder / 'lesson.txt'
path.write_text('Python:15\n读书:10\n', encoding='utf-8')
text = path.read_text(encoding='utf-8')
print(path.name)
print(len(text.splitlines()))

输出:

文本
lesson.txt
2

这里的相对路径以程序的当前工作目录为起点,不是无论在哪里运行都自动相对于脚本所在目录。自己在本地先查看 Path.cwd(),再找到生成的文件。重复运行会更新这份练习文件,使用专门的练习目录,别拿自己的笔记文件作实验。

浏览器里的同一段代码写入的是运行时虚拟文件系统,不是电脑上可长期保存的那个目录;刷新或重建运行环境后也不保证保留。需要跨次保存的完整项目,请在本地完成。

自己给内容增加第三行,再检查行数。然后只创建一个 Path 对象,不调用写入方法,观察它并不会凭空产生文件。需要完整数据时,也不要为了不报错就一律忽略解码错误;先确认文件编码和数据能否丢失。

读完了?动手练一遍才算真会。

去闯关模式练习 →
← 上一章17 · 代码可读性
pathlib:现代化路径处理
下一章 →19 · 异常处理
本章目录

pathlib:现代化路径处理

  1. 教学 01pathlib 路径处理
  2. 教学 02一、第一个 Path
  3. 教学 03二、用 `/` 来拼接路径
  4. 教学 04三、常用属性,一锅端
  5. 教学 053.1 `.name`:完整文件名(带后缀)
  6. 教学 063.2 `.stem`:去掉后缀的「主干」
  7. 教学 073.3 `.suffix`:扩展名(带点)
  8. 练习 1提取路径的 name / suffix / stem
  9. 练习 2用 / 操作符拼接路径
  10. 教学 083.4 `.parent`:父目录
  11. 教学 093.5 `.parents`:所有祖先目录
  12. 练习 3拿父目录和祖先目录
  13. 练习 4多后缀文件:.suffixes
  14. 教学 103.6 `.anchor`:锚点
  15. 教学 113.7 综合演示
  16. 教学 12四、判断和信息
  17. 教学 13五、遍历目录
  18. 教学 14六、读写文件
  19. 教学 15七、创建和删除
  20. 教学 16八、路径转换
  21. 教学 17九、改个名字:`with_name` / `with_stem` / `with_suffix`
  22. 教学 18十、跨平台:`PurePath` 系列
  23. 教学 19十一、老写法对照表 + 常见踩坑
  24. 教学 20十二、小实战:递归统计某目录下所有 `.py` 文件的总行数
  25. 教学 21十三、彩蛋:和老 API 互通
  26. 教学 22十四、小结
  27. 教学 23文件写到哪里了

学习目录