本节目标:用 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.walk | Path.walk |
|---|---|---|
| 最低版本 | 所有版本 | 3.12+ |
| 返回类型 | str 路径 | Path 对象 |
| 自底向上 | topdown=False | top_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——一旦中途出错,一半改了名、一半没改,你还得猜哪些动过。正确做法分两步:
- 遍历一遍,算出
(源, 目标)计划表,只打印不执行(dry-run)。 - 人工确认计划无误,再执行,且每个目标先查是否已存在。
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 安全删除与干跑
删除是最危险的操作。三条纪律:
- 默认 dry-run:脚本默认只打印「将删除 X」,加
--yes/--apply才真删。 - 优先移入回收站而非直接删:用
shutil.move把文件挪到同盘的.trash/目录,比unlink可回滚。真删需要send2trash这类库(本机未安装),所以下面用「移到.trash」的等价实现。 - 清理空目录自底向上:文件挪走后再删空目录,顺序不能反。
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 时永远只打印。批处理脚本的默认行为应该是「什么都没发生」,而不是「手一抖全删了」。
延伸阅读
- Python 文件 IO 与序列化 —— pathlib、编码、JSON/CSV/Pickle 的深入用法与陷阱
- Python 命令行程序:argparse 与 Click —— 给批处理脚本加上规范的参数与子命令
小结
- 路径用
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 自动化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。