Python 文件操作实战:安全整理下载目录

用 pathlib 写安全的文件整理脚本:预览模式、防覆盖、递归扫描与原子替换。

技术文章约 12 分钟211 次阅读

文件脚本大多死在同一个问题上:没想清楚「如果文件已经存在」「如果目录不存在」「如果权限不够」这三种情况。这篇文章用 Python 标准库的 pathlib 与 shutil 写一个「整理下载目录」的脚本,重点讲预览模式、防覆盖和错误处理。这些细节背后是一个更基本的机制:文件系统对"修改"几乎不提供事务保障——复制的中间状态会真实存在,改名的成败取决于是否在同一文件系统内,删错一个目录项则数据立即不可达。理解了这些底层约束,才知道什么时候必须预览、什么时候必须原子替换、什么时候必须逐个容错。

#路径的本质:目录项与文件系统

#文件名是目录项,不是文件本身

在 POSIX 文件系统里,"文件"由两部分组成:

  1. inode:保存内容与元数据(大小、权限、时间戳、数据块位置)的对象;
  2. 目录项(directory entry):目录文件里的一条记录,把「名字 → inode 编号」映射起来。

/Users/me/Downloads/a.png 只是从根目录出发、逐级查目录项得到的一条寻址路径。由此推出三个核心事实:

  • 复制 = 读取源 inode 的数据,另建新 inode;
  • 改名或移动 = 修改目录项(见"rename"一节);
  • 删除 = 移除目录项,并按引用计数释放 inode(若没有其他硬链接指向它)。

这三个事实解释了后面几乎所有行为:为什么同一文件系统内 move 很快而跨文件系统很慢;为什么覆盖旧文件必须先想清楚;为什么原子替换能保证「要么完整、要么原样不动」。

#pathlib 把路径建模为对象

pathlib(Python 3.4+)把路径寻址封装成 Path 对象:拼接、父目录、后缀、展开 ~、跨平台分隔符都由库处理,不再手工拼字符串:

from pathlib import Path

home = Path.home()
downloads = home / "Downloads"
target = downloads / "images" / "a.png"

print(downloads.exists())      # 布尔判断
print(target.suffix)           # .png
print(target.stem)             # a
print(target.with_suffix(".jpg"))  # images/a.jpg

注意 / 运算符是拼接,不要写成字符串相加。Path 对象之间、Path 与字符串混用时,先统一成 Path 再操作。Path 本身只是路径的语义化包装,exists()、mkdir() 等方法才真正触发系统调用(stat、mkdir 等),失败时抛出对应的 OSError 子类——这正是后面要逐个捕获的异常来源。

验证:把上面代码存为 path_demo.py 并运行 python3 path_demo.py,应看到四行输出,最后一行是 images/a.jpg;把 a.png 换成一个不存在的名字,exists() 输出 False 而不是抛异常。

#分类整理:先预览,后执行

#为什么默认不真动手

直接 shutil.move 的脚本,一旦分类映射或判断写错,文件就进了错误目录;更糟的是同名覆盖会让旧文件的数据立即不可达。整理脚本本质是「批量修改目录项」,属于不可逆操作的组合,所以默认「只打印要做什么」,加 --apply 才真正执行:

from __future__ import annotations
import argparse
import shutil
from pathlib import Path

CATEGORIES = {
    ".jpg": "images", ".jpeg": "images", ".png": "images", ".gif": "images",
    ".pdf": "documents", ".docx": "documents", ".txt": "documents",
    ".zip": "archives", ".tar.gz": "archives", ".rar": "archives",
}

def unique_destination(path: Path) -> Path:
    if not path.exists():
        return path
    for index in range(1, 10000):
        candidate = path.with_name(f"{path.stem}_{index}{path.suffix}")
        if not candidate.exists():
            return candidate
    raise RuntimeError(f"无法为 {path.name} 生成唯一名称")

def organize(folder: Path, apply: bool) -> None:
    for source in folder.iterdir():
        if not source.is_file() or source.name.startswith("."):
            continue
        category = CATEGORIES.get(source.suffix.lower(), "others")
        destination = unique_destination(folder / category / source.name)
        print(f"{source.name} -> {destination.relative_to(folder)}")
        if apply:
            destination.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(source), str(destination))

def main() -> None:
    parser = argparse.ArgumentParser(description="按扩展名整理下载目录")
    parser.add_argument("folder", type=Path, nargs="?", default=Path.home() / "Downloads")
    parser.add_argument("--apply", action="store_true", help="真正移动文件,默认只预览")
    args = parser.parse_args()
    organize(args.folder, apply=args.apply)

if __name__ == "__main__":
    main()

#关键细节与机制

  • CATEGORIES.get(source.suffix.lower(), "others"):.JPG 和 .jpg 都能命中(后缀小写化后查映射表),认不出的进 others,别让未知类型抛异常。注意边界:Path.suffix 只取最后一段,所以 a.tar.gz 的 suffix 是 .gz,永远不会命中映射表里的 ".tar.gz";要支持这种多段后缀,需单独判断 source.name.endswith(".tar.gz")。
  • unique_destination:同名文件自动加 _1、_2,而不是覆盖。覆盖是文件脚本最危险的默认行为:open(path, "w") 或 copy2 会先截断/覆盖旧文件,一旦中途失败,旧内容无法恢复。
  • iterdir() 返回当前目录的一层目录项;source.is_file() 对符号链接会跟随目标判断;source.name.startswith(".") 跳过隐藏文件(.DS_Store、~ 临时文件等),防止把系统文件挪走。
  • destination.parent.mkdir(parents=True, exist_ok=True):parents=True 递归创建父目录,exist_ok=True 在目录已存在时不报错——这正是「目录不存在」失败模式的应对。
  • 打印的是相对路径,方便人核对;日志要可读,别打一长串绝对路径。

#验证路径

把上面的代码保存为 organize_script.py(含 main() 入口),然后:

mkdir -p /tmp/dl_test && cd /tmp/dl_test
touch a.pdf b.PNG c.unknown .DS_Store
python3 organize_script.py /tmp/dl_test

预期:只打印三条映射(.DS_Store 被跳过、b.PNG 小写化后命中 images),且目录里文件原地不动。接着执行 python3 organize_script.py /tmp/dl_test --apply,用 ls -R /tmp/dl_test 检查 documents/、images/、others/ 是否各归其位;再重复执行一次 --apply,确认同名文件得到 _1 后缀而不是被覆盖。

#递归扫描:glob 与 rglob 的区别

iterdir() 只列出当前目录一层。要递归,用 glob(匹配当前目录下的通配模式)或 rglob(递归匹配):

for pdf in folder.rglob("*.pdf"):
    print(pdf)

#为什么 rglob 可能越界

rglob 按目录树深度优先遍历所有子目录,包括 __pycache__、.git 这类你未必想碰的目录;更隐蔽的是它会跟随符号链接进入链接指向的目录——如果链接指回自身或祖先目录,就形成循环;即使不循环,也可能把扫描范围扩大到目录外的区域。扫描前先想清楚要不要 follow symlink:

for item in folder.rglob("*"):
    if item.is_symlink():
        continue          # 跳过符号链接,防止循环引用
    if item.is_file():
        print("file:", item)

Path.is_symlink() 只检查目录项本身是否为符号链接,不跟随目标,因此安全。若确实要处理链接指向的文件,先 resolve() 出真实路径,再确认它仍在目标目录内,防止越界移动。

#验证

mkdir -p /tmp/dl_test/sub/deep
ln -s /tmp/dl_test/sub /tmp/dl_test/loop_link   # 故意制造一个环
python3 - <<'PY'
from pathlib import Path
n = 0
for item in Path("/tmp/dl_test").rglob("*"):
    if item.is_symlink():
        n += 1
        print("skip symlink:", item)
print("skipped", n)
PY

带 is_symlink 守卫时脚本应正常结束并打印 1 条 skip;去掉守卫再跑,rglob 会在循环里越走越深,最终以 RecursionError 或「too many levels of symbolic links」报错收场——这就是必须跳过符号链接的直接证据。

#错误处理:谁出错就记谁,别让整个脚本崩掉

#失败模式从哪里来

对目录项的每次系统调用都可能失败,常见几类:

  • PermissionError(Errno 13):目录属于别的用户、文件只读、目录缺少写权限;
  • OSError: [Errno 28] No space left on device:磁盘满,写操作无法落盘;
  • 目标父目录不存在且无法创建;
  • 扫描后文件被其他程序删除或替换(检查与操作之间的竞态)。

一个文件失败,不应中断整个整理任务,但失败必须可见。做法是逐文件 try/except、收集错误、最后统一报告:

def organize(folder: Path, apply: bool) -> None:
    errors = []
    for source in folder.iterdir():
        try:
            category = CATEGORIES.get(source.suffix.lower(), "others")
            destination = unique_destination(folder / category / source.name)
            if apply:
                destination.parent.mkdir(parents=True, exist_ok=True)
                shutil.move(str(source), str(destination))
        except (PermissionError, OSError) as e:
            errors.append((source.name, str(e)))
    if errors:
        print("以下文件处理失败:")
        for name, msg in errors:
            print(f"  {name}: {msg}")
        raise SystemExit(1)

注意 except (PermissionError, OSError):PermissionError 是 OSError 的子类,两者并列虽冗余但无害,能保证 PermissionError 与 Errno 28 等其它 OSError 都被捕获;只捕获 Exception 则会连编码错误也吞掉,不利于定位问题。

#磁盘满时为什么危险

磁盘满时 shutil.move 可能留下半个文件——跨文件系统移动尤其危险。原因在 move 的实现:同文件系统走 rename,跨文件系统则「先复制、再删除源」。当前 CPython 实现会在复制成功后才删除源文件,所以满盘时源文件通常还在,但目标侧残留半个文件;断电则更糟:复制到一半,目标只有半截数据,重启后两边状态不一致,若此时误判「源已安全」并继续处理,数据就真的丢了。因此对重要数据,正确姿势是「先复制到临时名、校验成功后原子替换或删除源」,而不是裸 move。

#验证

chmod 400 /tmp/dl_test/readonly.pdf   # 制造一个只读文件
python3 organize_script.py /tmp/dl_test --apply
echo "exit=$?"

脚本应继续处理其他文件,结束时打印"以下文件处理失败"并列出 readonly.pdf,退出码为 1。恢复权限用 chmod 644 /tmp/dl_test/readonly.pdf。

#跨文件系统移动与原子替换

#rename:同一文件系统内是元数据操作

os.rename(以及同文件系统时的 shutil.move)做的不是复制数据,而是在目录里改一条目录项:把新名字指向源文件的 inode,再删除旧目录项。inode 与数据块不动,因此:

  • 速度与文件大小几乎无关,瞬间完成;
  • 没有"复制一半"的中间状态,逻辑上是原子的;
  • 失败只来自权限或只读文件系统,失败时什么都不会发生。

#跨设备时为什么变成复制

rename 的系统调用有一个硬限制:只能在同一文件系统(挂载点)内改名。源与目标不在同一文件系统时,内核返回 EXDEV(跨设备链接)。shutil.move 捕获后降级为「复制 + 删除」:

复制源文件 -> 在目标文件系统写满数据 -> 删除源文件

这个降级有三个后果:

  1. 慢:数据要真实写入另一块磁盘;
  2. 非原子:复制到一半断电或满盘,目标只有半个文件;
  3. 失败窗口:复制成功但删除失败会出现两份拷贝;复制失败可能残留半截目标文件(现代实现不会先删源,但不能依赖调用方的异常处理顺序)。

如何预判会不会跨文件系统:df 看源与目标是否同一挂载点,或比较 os.stat() 的设备号:

df -h ~/Downloads /tmp   # 对比 Filesystem 列是否相同

#为什么需要原子替换

「更新一个已存在的文件」是另一类任务:先写新内容、再替换旧内容。若直接 open(final_path, "w") 写入,写第一行时崩溃,旧文件已被截断——新旧内容同时丢失。os.replace 解决这个问题:它把临时文件的名字在同一文件系统内原子地换成目标名,要么新内容完全就位,要么原样不动:

import os
from pathlib import Path

def atomic_write(path: Path, content: str) -> None:
    tmp = path.with_name(f".{path.name}.tmp")
    tmp.write_text(content, encoding="utf-8")
    os.replace(tmp, path)   # 原子替换,进程崩溃也不会留半个文件

先写临时文件、再 os.replace 到目标名,是「写配置文件」「更新导出文件」的标准安全写法。两个前提:临时文件放在同一目录(保证同文件系统,不会触发 EXDEV);os.replace 会覆盖目标,若想留旧版本,先 os.replace(path, backup) 再替换。

#验证

python3 - <<'PY'
from pathlib import Path
import os
p = Path("/tmp/atomic_demo.txt")
p.write_text("old", encoding="utf-8")
tmp = p.with_name(".atomic_demo.txt.tmp")
tmp.write_text("new-content", encoding="utf-8")
os.replace(tmp, p)
print(p.read_text(encoding="utf-8"))   # new-content
PY
ls -a /tmp | grep atomic_demo   # 应无 .tmp 残留

强校验:把内容换成几十 MB、在写入与 replace 之间重复运行并中途 kill 进程,观察 /tmp/atomic_demo.txt——它要么是旧内容,要么是完整新内容,绝不会是半截文件,因为目标文件从未被打开写入。

#编码:显式指定,不靠默认值

Path.read_text() / write_text() 与 open() 的文本模式都依赖系统默认编码(locale)。Linux/macOS 通常是 UTF-8,但 Windows 中文系统是 GBK(cp936):中文内容直接乱码,非法字节还会抛 UnicodeDecodeError。任何文本读写都应显式传 encoding="utf-8":

text = source.read_text(encoding="utf-8")          # 读
source.write_text(text, encoding="utf-8")          # 写
with open(source, "r", encoding="utf-8") as f:     # open 同样显式指定
    data = f.read()

验证思路:在 Windows 或 PYTHONUTF8=0 的环境中,用含中文的文本分别以「不传 encoding」与「encoding="utf-8"」写入,再用 xxd 或十六进制编辑器对比字节:显式版本是标准 UTF-8 字节(一个汉字 3 字节),不显式版本可能得到 GBK 字节(一个汉字 2 字节)。

#动手前看一眼:du -sh

动重要目录前先看总量,避免"把十几 GB 复制到另一块盘"这类意外:

du -sh ~/Downloads          # 总大小
du -sh ~/Downloads/*        # 逐项看大头
df -h ~/Downloads           # 所在文件系统及剩余空间

du 统计实际占用的块数,ls -l 显示逻辑大小,稀疏文件与小文件簇场景两者不一致——判断"实际要搬多少数据"以 du 为准。

#总结:四条铁律与一个坑

文件脚本最大的风险不是逻辑错,而是人在没有预览的情况下跑了 --apply。经验浓缩为四条:

  1. 默认预览,--apply 才动手;
  2. 目标路径永远生成唯一名,绝不覆盖;
  3. 每个文件单独 try/except,记录失败不中断;
  4. 动重要目录前先 du -sh 看一眼总量,心里有数再跑。

另外 Path.read_text() / write_text() 记得显式传 encoding="utf-8",否则在 Windows 上会按 GBK 读写,中文直接乱码——这个坑我帮同事排查过整整一个下午。

#什么时候不该用这个脚本

  • 目录里还有正在写入的文件(如下载中的 .part、.crdownload):扫描后移动可能搬走半截文件,应先过滤这些后缀;
  • 需要「合并目录、按时间保留最新版本」的同步场景:这是 rsync 的领域,不要手写 move 去模拟;
  • 目标在另一块盘且数据量巨大:跨文件系统 move 本质是复制,先 df 确认空间足够,再决定是否分批执行。

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

Python 文件操作实战:安全整理下载目录 · LXH·BLOG