pathlib + shutil 批量文件管理实战:重命名、归档与增量备份

一、为什么 pathlib 优于 os.path

传统代码经常这样拼接路径:

1
2
import os
path = os.path.join("data", "reports", "today.csv")

pathlib 将路径封装成 Path 对象:

1
2
from pathlib import Path
path = Path("data") / "reports" / "today.csv"

它的优势包括:

  • / 运算符拼接路径,避免手写分隔符;
  • path.exists()path.is_file()path.stat() 语义清晰;
  • path.read_text()path.write_text() 适合小型文本文件;
  • glob()rglob()iterdir() 可以直接遍历;
  • 同一套代码可运行于 Linux、macOS 和 Windows。

Path 适合真实文件系统;PurePath 只进行路径计算,不访问磁盘,适合测试和解析外部输入。

二、核心 API

iterdir() 遍历当前目录,glob("*.log") 匹配当前层,rglob("*.log") 递归匹配。生产脚本要明确是否跟随符号链接,避免目录循环。

1
2
3
4
5
6
from pathlib import Path

root = Path("data")
for file in root.rglob("*.log"):
if file.is_file():
print(file, file.stat().st_size)

对于大量文件,避免先转换成巨大的列表。使用迭代器可以降低内存峰值。删除和移动操作则必须增加 dry-run 预览,尤其是清理脚本。

三、实战 1:批量重命名

下面脚本把目录中的素材统一命名为 project_0001.ext。它先生成计划,再执行,且通过临时名称避免重命名互相覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from pathlib import Path

ROOT = Path("photos")
PREFIX = "project"
DRY_RUN = True

files = sorted(
p for p in ROOT.iterdir()
if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png"}
)

plan = [
(src, ROOT / f"{PREFIX}_{i:04d}{src.suffix.lower()}")
for i, src in enumerate(files, 1)
]

for src, dst in plan:
print(f"{src.name} -> {dst.name}")

if not DRY_RUN:
temp_plan = []
for index, (src, dst) in enumerate(plan):
temp = ROOT / f".rename_tmp_{index}"
src.rename(temp)
temp_plan.append((temp, dst))

for temp, dst in temp_plan:
if dst.exists():
raise FileExistsError(dst)
temp.rename(dst)

重命名前不要依赖文件系统返回顺序。sorted() 能让结果可复现,也便于回滚和审计。

四、实战 2:按 EXIF 时间归档照片

照片可以通过 EXIF 拍摄时间归档到 年/月/日。生产环境中要考虑没有 EXIF 的文件,脚本可以回退到文件修改时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from datetime import datetime
from pathlib import Path
import shutil

try:
from PIL import Image
from PIL.ExifTags import TAGS
except ImportError:
raise SystemExit("请先执行: python -m pip install pillow")

SOURCE = Path("incoming")
DEST = Path("archive")
DRY_RUN = True

def photo_time(path: Path) -> datetime:
try:
with Image.open(path) as image:
exif = image.getexif()
for key, value in exif.items():
if TAGS.get(key) == "DateTimeOriginal":
return datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S")
except Exception:
pass
return datetime.fromtimestamp(path.stat().st_mtime)

for photo in SOURCE.rglob("*"):
if not photo.is_file() or photo.suffix.lower() not in {".jpg", ".jpeg", ".png"}:
continue

when = photo_time(photo)
target_dir = DEST / f"{when:%Y}" / f"{when:%m}" / f"{when:%d}"
target = target_dir / photo.name

if target.exists():
target = target_dir / f"{photo.stem}_{photo.stat().st_size}{photo.suffix}"

print(f"{photo} -> {target}")
if not DRY_RUN:
target_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(photo), str(target))

归档脚本应保留原始目录结构或生成清单,否则同名文件和后续追溯都会困难。

五、实战 3:mtime 增量备份

最简单的增量策略是比较源文件和目标文件的修改时间及大小。更严格的备份可以比较 SHA-256,但会增加磁盘读取量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from pathlib import Path
import shutil

SOURCE = Path("project")
BACKUP = Path("backup/project")
DRY_RUN = False

for src in SOURCE.rglob("*"):
if not src.is_file():
continue

relative = src.relative_to(SOURCE)
dst = BACKUP / relative
changed = (
not dst.exists()
or src.stat().st_size != dst.stat().st_size
or src.stat().st_mtime_ns > dst.stat().st_mtime_ns
)

if changed:
print("backup:", relative)
if not DRY_RUN:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)

copy2() 会尽量保留修改时间等元数据。备份到对象存储时,可使用 rclone、云厂商 CLI 或 SDK;不要把密钥硬编码进脚本。

六、实战 4:清理旧日志

清理任务必须限定目录、限定后缀,并先打印待删除文件:

1
2
3
4
5
6
7
8
9
10
11
12
from datetime import datetime, timedelta
from pathlib import Path

LOG_DIR = Path("/var/log/myapp")
DAYS = 7
cutoff = datetime.now().timestamp() - timedelta(days=DAYS).total_seconds()

for path in LOG_DIR.glob("*.log"):
if path.is_file() and path.stat().st_mtime < cutoff:
print("remove:", path)
# 确认后再取消下一行注释
# path.unlink()

不要直接对用户传入的路径执行 rmtree()shutil.rmtree() 只能用于明确创建的临时目录,并且应检查其是否位于允许的根目录之下。

七、shutil 高级用法

copytree(src, dst, dirs_exist_ok=True) 可合并复制目录;ignore 参数可排除缓存和构建产物:

1
2
3
4
5
6
7
8
9
10
11
12
import shutil
from pathlib import Path

def ignore_build(directory: str, names: list[str]) -> set[str]:
return {name for name in names if name in {".git", "__pycache__", "node_modules"}}

shutil.copytree(
Path("src"),
Path("backup/src"),
dirs_exist_ok=True,
ignore=ignore_build,
)

move() 在同一文件系统中通常是重命名,跨文件系统时可能退化为复制后删除。备份关键数据时应验证目标文件大小或哈希。

八、踩坑记录与跨平台测试

Windows 路径可能超过传统 260 字符限制,尽量缩短目录层级,并使用较新的 Windows API。符号链接可能造成 rglob() 循环,处理链接时检查 path.is_symlink()。Unicode 文件名在 Linux 通常是 UTF-8,在 Windows 则要避免依赖系统默认编码。

测试时不要只在自己的机器上运行。可以使用 PurePosixPathPureWindowsPath 验证路径计算:

1
2
3
4
from pathlib import PurePosixPath, PureWindowsPath

assert str(PurePosixPath("a") / "b") == "a/b"
assert str(PureWindowsPath("a") / "b") == r"a\b"

九、小结

任务 推荐 API
路径拼接 Path / "name"
递归查找 rglob()
文件复制 shutil.copy2()
目录复制 shutil.copytree()
安全删除 unlink()、受控 rmtree()
跨平台路径计算 PurePath

无论是重命名、归档还是备份,都应遵循“预览、校验、执行、记录”四步流程。


pathlib + shutil 批量文件管理实战:重命名、归档与增量备份
https://blog.calcguide.tech/2026-08-10-pathlib批量文件管理实战/
作者
王争气
发布于
2026年8月10日
许可协议