《Python编程实战》13.1 文件批处理与目录治理

文件批处理最怕「一跑就毁数据」:本节用 pathlib / shutil / hashlib 真跑批量重命名、按规则归类、内容哈希去重与空目录清理,并讲透「先干跑再执行」的工程纪律、原子重命名与跨盘移动,以及 os.walk 对比 Path.walk 的取舍。

本节目标:用 pathlib / shutil / hashlib 写出「先干跑、再执行」的批量文件脚本,安全完成重命名、归类、去重与清理,并搞清 os.walk 与 Path.walk 的取舍。
适用版本:Python 3.12+(实测 3.14.6);仅用标准库

13.1 文件批处理与目录治理

前两章我们跟「数据」打交道——pandas 清洗、HTTP 抓取。这一章回到本地磁盘:文件、目录、文档。别小看它,批量文件操作是「一次性脚本」变成「生产事故」的高发区:一个 glob 写错,rm 下去就没了;一次重命名没查冲突,两个文件互相覆盖。本节的核心不是 API 清单,而是一套先规划、后执行、可回滚的工程套路。

13.1.1 别再用字符串拼路径

老代码里常见 os.path.join(BASE, name)、BASE + "/" + name,Windows 上还会踩反斜杠。3.6 起 pathlib.Path 才是正解:路径是对象,/ 运算符拼接,读属性拿 .name / .stem / .suffix / .parent。

from pathlib import Path

p = Path("/tmp/python_book/scratch/13/demo1/photos/IMG_20240103_142233.jpg")
print(p.name)          # 文件名(含扩展名)
print(p.stem)          # 去扩展名的主名
print(p.suffix)        # 扩展名(含点)
print(p.parent)        # 父目录
print(p.parent.name)   # 父目录名

真实输出:

IMG_20240103_142233.jpg
IMG_20240103_142233
.jpg
/tmp/python_book/scratch/13/demo1/photos
photos

三个容易混的 API:.with_suffix(".txt") 换扩展名、.with_name("x.txt") 换整个文件名、.with_stem("x") 只换主名。它们返回新对象,不修改原路径,所以可以安全地拿来做「目标路径」。

13.1.2 遍历:os.walk vs Path.walk

遍历目录树,两套 API 都能用。os.walk 是元老,Path.walk 是 3.12 新增(PEP 有对应,见版本分界表),签名几乎一致,但返回 Path 对象:

import os
from pathlib import Path

ROOT = Path("/tmp/python_book/scratch/13/demo1")
for dirpath, dirnames, filenames in os.walk(ROOT):
    print("os.walk  ", Path(dirpath).relative_to(ROOT), dirnames, sorted(filenames))

for dirpath, dirnames, filenames in ROOT.walk():
    print("Path.walk", dirpath.relative_to(ROOT), dirnames, sorted(filenames))

真实输出(两者结果一致):

os.walk   . ['docs', 'photos'] []
os.walk   docs [] ['copy_b.txt', 'notes.txt', 'report.pdf']
os.walk   photos [] ['copy_a.txt', 'IMG_20240103_142233.jpg', 'IMG_20240105_090101.jpg']
Path.walk . ['docs', 'photos'] []
Path.walk docs [] ['copy_b.txt', 'notes.txt', 'report.pdf']
Path.walk photos [] ['copy_a.txt', 'IMG_20240103_142233.jpg', 'IMG_20240105_090101.jpg']

怎么选?

维度os.walkPath.walk
最低版本所有版本3.12+
返回类型str 路径Path 对象
自底向上topdown=Falsetop_down=False
与 pathlib 风格需手动包 Path()原生一致

新代码一律用 Path.walk——省掉反复的 Path(dirpath) 包装,和 .stem / .with_name 直接衔接。若你的库要兼容 3.11,退回 os.walk。

top_down=False(自底向上)是清理场景的关键:先删文件、再删空目录,因为父目录只有在子目录都空了之后才能删。顺序搞反会留下删不掉的空壳。

13.1.3 批量重命名:先算计划,再动手

假设要把手机相册的 IMG_20240103_142233.jpg 改成 2024-01-03_142233.jpg。错误示范是边遍历边 rename——一旦中途出错,一半改了名、一半没改,你还得猜哪些动过。正确做法分两步:

  1. 遍历一遍,算出 (源, 目标) 计划表,只打印不执行(dry-run)。
  2. 人工确认计划无误,再执行,且每个目标先查是否已存在。
from pathlib import Path

ROOT = Path("/tmp/python_book/scratch/13/demo1")

def plan_rename(root: Path) -> list[tuple[Path, Path]]:
    plan = []
    for p in sorted(root.rglob("IMG_*.jpg")):
        parts = p.stem.split("_")                 # IMG_20240103_142233
        date, time = parts[1], parts[2]
        new_name = f"{date[:4]}-{date[4:6]}-{date[6:]}_{time}.jpg"
        plan.append((p, p.with_name(new_name)))
    return plan

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

真实输出:

IMG_20240103_142233.jpg -> 2024-01-03_142233.jpg
IMG_20240105_090101.jpg -> 2024-01-05_090101.jpg

确认后再执行,并显式处理冲突:

for p in sorted(ROOT.rglob("IMG_*.jpg")):
    parts = p.stem.split("_")
    new = p.with_name(f"{parts[1][:4]}-{parts[1][4:6]}-{parts[1][6:]}_{parts[2]}.jpg")
    if new.exists():
        print(f"跳过(目标已存在): {new.name}")
        continue
    p.rename(new)                 # 同目录 rename 是原子的
    print(f"{p.name} -> {new.name}")

真实输出:

IMG_20240103_142233.jpg -> 2024-01-03_142233.jpg
IMG_20240105_090101.jpg -> 2024-01-05_090101.jpg

两个要点:

  • 同一文件系统内 rename 是原子的——要么成功要么没动,不会出现「半个文件」。但跨文件系统(比如从 /tmp 到外接盘)rename 会抛 OSError,得用 shutil.move(它内部退化为 copy + delete)。
  • new.exists() 检查不是绝对安全:检查与 rename 之间理论上有竞态(TOCTOU)。单机批处理通常可接受;高并发场景要用 os.open(..., O_EXCL) 之类原子原语。别把「检查」当成「锁」。

13.1.4 按规则归类:把混乱目录整理成结构

「下载文件夹一团乱,想按扩展名分到 images/ pdf/ text/」。同样先出计划:

from pathlib import Path

ROOT = Path("/tmp/python_book/scratch/13/demo1")
buckets = {".jpg": "images", ".png": "images", ".pdf": "pdf", ".txt": "text"}

def plan_classify(root: Path) -> list[tuple[Path, Path]]:
    plan = []
    for p in sorted(root.rglob("*")):
        if not p.is_file() or p.parent.name in {"images", "pdf", "text"}:
            continue                              # 已在目标桶里的别再动
        bucket = buckets.get(p.suffix.lower())
        if bucket:
            plan.append((p, root / bucket / p.name))
    return plan

for src, dst in plan_classify(ROOT):
    print(f"{src.relative_to(ROOT)} -> {dst.relative_to(ROOT)}")

真实输出:

docs/copy_b.txt -> text/copy_b.txt
docs/notes.txt -> text/notes.txt
docs/report.pdf -> pdf/report.pdf
photos/IMG_20240103_142233.jpg -> images/IMG_20240103_142233.jpg
photos/IMG_20240105_090101.jpg -> images/IMG_20240105_090101.jpg
photos/copy_a.txt -> text/copy_a.txt

执行时用 shutil.move,并先 mkdir(parents=True, exist_ok=True):

import shutil

def apply_plan(root: Path, plan, *, dry_run: bool) -> None:
    for src, dst in plan:
        if dry_run:
            print(f"[dry-run] {src.name} -> {dst}")
            continue
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(str(src), str(dst))          # 跨目录/跨盘都安全
        print(f"[move]    {src.name} -> {dst.relative_to(ROOT)}")

真实输出(先 dry-run 再执行):

== 先干跑 ==
[dry-run] copy_b.txt -> /tmp/python_book/scratch/13/demo1/text/copy_b.txt
[dry-run] notes.txt -> /tmp/python_book/scratch/13/demo1/text/notes.txt
[dry-run] report.pdf -> /tmp/python_book/scratch/13/demo1/pdf/report.pdf
[dry-run] 2024-01-03_142233.jpg -> /tmp/python_book/scratch/13/demo1/images/2024-01-03_142233.jpg
[dry-run] 2024-01-05_090101.jpg -> /tmp/python_book/scratch/13/demo1/images/2024-01-05_090101.jpg
[dry-run] copy_a.txt -> /tmp/python_book/scratch/13/demo1/text/copy_a.txt

== 确认后执行 ==
[move]    copy_b.txt -> text/copy_b.txt
[move]    notes.txt -> text/notes.txt
[move]    report.pdf -> pdf/report.pdf
[move]    2024-01-03_142233.jpg -> images/2024-01-03_142233.jpg
[move]    2024-01-05_090101.jpg -> images/2024-01-05_090101.jpg
[move]    copy_a.txt -> text/copy_a.txt

注意 p.parent.name in {...} 这道过滤——别把已经归好类的文件又搬一次。这类「排除目标目录」的判断,是幂等脚本的常见前置条件:重复跑第二次应该什么都不做。

13.1.5 重复文件检测:用内容哈希,不用文件名

清理磁盘时「找重复文件」是高频需求。绝不能靠文件名或大小判断——report_final.pdf 和 report_v2.pdf 可能内容完全一样,也可能差一个字节。正确判据是内容哈希。

性能上有个关键优化:先按文件大小分组,只有同组内 ≥2 个文件才去算哈希。因为不同大小必然不同内容,没必要浪费 I/O:

import hashlib
from collections import defaultdict
from pathlib import Path

def file_sha256(path: Path, chunk: int = 1 << 20) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        while block := f.read(chunk):            # 分块读,大文件不爆内存
            h.update(block)
    return h.hexdigest()

def find_duplicates(root: Path) -> dict[str, list[Path]]:
    by_size: dict[int, list[Path]] = defaultdict(list)
    for p in root.rglob("*"):
        if p.is_file():
            by_size[p.stat().st_size].append(p)

    by_hash: dict[str, list[Path]] = defaultdict(list)
    for size, files in by_size.items():
        if len(files) < 2:                       # 大小唯一,不可能重复
            continue
        for f in files:
            by_hash[file_sha256(f)].append(f)
    return {h: fs for h, fs in by_hash.items() if len(fs) > 1}

真实输出(copy_a.txt 与 copy_b.txt 内容相同):

== 重复检测 ==
cae1b3faaa5e ['docs/copy_b.txt', 'photos/copy_a.txt']

三个工程细节:

  • 分块读(1 << 20 = 1 MiB):f.read() 一次性读 10 GB 文件会直接把内存吃满。分块读的峰值内存只有 chunk 大小。
  • 大小预筛:这是最便宜的剪枝。真实磁盘上绝大多数文件大小唯一,预筛后要算哈希的文件通常不到 1%。
  • 哈希选 SHA-256 而非 MD5:这里其实 MD5 够用(只为判等,不涉安全),但 SHA-256 在现代 CPU 上有硬件加速,速度差距可忽略,且不会让人误会「MD5 用于安全场景」。追求极致速度可用 BLAKE2b(hashlib.blake2b),比 SHA-256 更快。

拿到重复组后,删除策略要交给人来定——保留哪一份?按修改时间最新、按路径最短、还是全留只报告?脚本只负责报告重复组,删除动作单独确认。

13.1.6 安全删除与干跑

删除是最危险的操作。三条纪律:

  1. 默认 dry-run:脚本默认只打印「将删除 X」,加 --yes / --apply 才真删。
  2. 优先移入回收站而非直接删:用 shutil.move 把文件挪到同盘的 .trash/ 目录,比 unlink 可回滚。真删需要 send2trash 这类库(本机未安装),所以下面用「移到 .trash」的等价实现。
  3. 清理空目录自底向上:文件挪走后再删空目录,顺序不能反。
from pathlib import Path

def clean_empty_dirs(root: Path, *, dry_run: bool) -> None:
    # 自底向上:先处理深层目录,父目录才有机会变空
    for d in sorted(root.rglob("*"), key=lambda x: -len(x.parts)):
        if d.is_dir() and not any(d.iterdir()):
            if dry_run:
                print("[dry-run] 删除空目录:", d.relative_to(root))
            else:
                d.rmdir()
                print("删除空目录:", d.relative_to(root))

真实输出(归类执行后,原 docs/ 与 photos/ 变空):

== 清空目录清理 ==
删除空目录: docs
删除空目录: photos

最终目录被整理成 images/、pdf/、text/ 三个桶,原 docs/、photos/ 两个空壳已清掉。

注意 rmdir() 只删空目录——目录里还有东西会抛 OSError,这正是我们要的安全网。想连内容一起删得用 shutil.rmtree(),那是「核选项」,务必先 dry-run 并确认路径。

把「先规划、后执行」做成脚本骨架,一个通用参数就够:

import argparse

def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("root", type=Path)
    ap.add_argument("--apply", action="store_true", help="真正执行(默认只干跑)")
    args = ap.parse_args()
    plan = plan_classify(args.root)
    apply_plan(args.root, plan, dry_run=not args.apply)

if __name__ == "__main__":
    main()

dry_run=not args.apply 这一行是精髓:不传 --apply 时永远只打印。批处理脚本的默认行为应该是「什么都没发生」,而不是「手一抖全删了」。

延伸阅读

小结

  • 路径用 pathlib 而非字符串拼接;.with_name / .with_suffix 返回新对象,天然适合算「目标路径」。
  • 遍历优先用 Path.walk(3.12+),兼容旧版本再用 os.walk;清理场景记得 top_down=False 自底向上。
  • 批量操作一律先算计划表 dry-run,再执行;重命名查目标冲突,跨盘用 shutil.move 而非 rename。
  • 重复文件检测用内容哈希,并用文件大小预筛剪枝;分块读取避免大文件爆内存。
  • 删除默认 dry-run、优先移入 .trash、空目录 rmdir 自底向上删;shutil.rmtree 是核选项,务必确认路径。

至此我们把「一堆散乱文件」整理成了可预期的目录结构。但办公场景里真正难啃的是文档本身——Excel 的公式与样式、Word 的模板、PDF 的合并拆分。下一节我们直接用 openpyxl / python-docx / pypdf 真跑一遍,把「手工填表」变成「脚本生成」。

阅读导航:上一节:稳定性、限速与合规边界 · 下一节:Excel / Word / PDF 自动化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时