Python 的 shutil 模块常被当作 os 的高级文件操作补充,用来复制、移动、删除和归档文件目录。但它的默认语义并不总是符合备份、清理和部署脚本的直觉:copy() 可能丢权限与时间戳,copytree() 在目标存在时直接报错,rmtree() 遇到只读文件会失败,move() 跨文件系统时并非原子操作。下面从行为差异、复现案例、解决代码和调试验证几个层面梳理。
一、复制函数族:copy()、copy2()、copyfile() 差异
原文给出的核心差异:
- copyfile():只复制内容,不保留权限、时间戳和元数据。
- copymode():仅复制权限。
- copystat():仅复制元数据,权限、时间戳等。
- copy():复制内容 + 权限,但不保留时间戳,元数据只部分保留。
- copy2():复制内容 + 全部元数据,相当于在 copy() 基础上调用 copystat(),保留权限、修改时间、访问时间等。
需要特别记住:copy2() 仍不复制所有者(chown)、扩展属性(xattr)和 ACL。若必须保留所有权,要在 Unix 上显式调用 os.chown(),而且通常需要 root 权限,shutil 不内置该功能。
场景 1:copy() 不保留元数据
- import shutil
- import os
- import time
- src = 'important.txt'
- dst = 'backup.txt'
- os.chmod(src, 0o600)
- old_time = time.time() - 86400
- os.utime(src, (old_time, old_time))
- shutil.copy(src, dst)
- print(oct(os.stat(dst).st_mode & 0o777))
- print(os.stat(dst).st_mtime)
复制代码
如果 src 原始权限是 0o600,copy() 后目标可能是 0o644;mtime 也会变成复制时间,而不是原始修改时间。做备份时应优先使用 copy2()。
二、copytree():目标目录存在与合并复制
copytree() 本质是递归地对每个条目调用 copy2(),并创建对应目录结构。关键参数包括:
- dirs_exist_ok=False:默认目标目录存在时抛异常。
- symlinks=False:默认解引用符号链接,复制链接指向的内容;设为 True 则复制链接本身。
- ignore:指定忽略模式,例如 shutil.ignore_patterns('*.pyc', '__pycache__')。
- copy_function:可自定义使用的复制函数。
场景 2 复现:
- import shutil
- shutil.copytree('source_dir', 'dest_dir')
复制代码
如果 dest_dir 已经存在,哪怕只是空目录,也会抛出 FileExistsError。在需要合并目录或覆盖已有目录时,这个默认行为不友好;如果目标目录是部分复制后失败留下的残留,再次运行也会因“目录已存在”而无法继续。
正确做法是使用 dirs_exist_ok=True(Python 3.8+),并明确 copy_function:
- import shutil
- shutil.copytree(
- 'src',
- 'dst',
- dirs_exist_ok=True,
- copy_function=shutil.copy2
- )
复制代码
还可以用 ignore 排除缓存和版本控制文件:
- import shutil
- shutil.copytree(
- 'project',
- 'backup',
- ignore=shutil.ignore_patterns('*.pyc', '__pycache__', '.git')
- )
复制代码
三、rmtree():只读文件、符号链接与错误处理
rmtree() 递归删除目录树。在 Unix 上,它使用 os.unlink() 删除文件、os.rmdir() 删除空目录。它默认不处理只读文件,因此遇到只读文件或只读目录时可能抛出 PermissionError。Python 3.12 引入了 onexc 参数替代旧的 onerror。
场景 3 复现:
- import shutil
- import os
- os.makedirs('readonly_dir')
- with open('readonly_dir/file.txt', 'w') as f:
- f.write('data')
- os.chmod('readonly_dir/file.txt', 0o444)
- os.chmod('readonly_dir', 0o555)
- shutil.rmtree('readonly_dir')
复制代码
在 Unix 上,删除只读文件需要先修改其权限或目录权限。若希望可靠清理,可以提供错误处理回调。旧版本使用 onerror:
- import os
- import stat
- import shutil
- def remove_readonly(func, path, excinfo):
- os.chmod(path, stat.S_IWRITE)
- func(path)
- shutil.rmtree('readonly_dir', onerror=remove_readonly)
复制代码
Python 3.12+ 使用 onexc:
- import os
- import stat
- import shutil
- def remove_readonly(func, path, exc):
- os.chmod(path, stat.S_IWRITE)
- func(path)
- shutil.rmtree('readonly_dir', onexc=remove_readonly)
复制代码
另一个容易忽视的问题是符号链接。场景 5 中:
- import shutil
- import os
- os.makedirs('real_dir')
- os.symlink('real_dir', 'link_to_dir')
- shutil.rmtree('link_to_dir')
复制代码
在某些 Python 版本和平台组合下,rmtree() 对符号链接的处理可能不符合预期,存在安全隐患。原文指出:Python 3.3+ 的 rmtree() 在遇到符号链接时,如果链接指向目录,会直接删除链接本身,而不是递归进入;但如果使用自定义 onerror,可能引入风险。因此应尽量使用标准库默认行为,不要手动处理符号链接。
四、move():跨文件系统与覆盖差异
move() 首先尝试 os.rename(),在同一文件系统上这是原子操作。如果失败(例如不同文件系统返回 EXDEV),就退化为 copy2() + os.unlink() 来模拟移动。
- import shutil
- shutil.move('/mnt/disk1/large_file.iso', '/mnt/disk2/large_file.iso')
复制代码
跨文件系统时,复制过程中若失败,源文件不会被删除;如果复制成功而 unlink 失败,源文件会保留,目标文件存在,需要手动处理。因此 move() 在跨设备场景下并非原子操作。移动前应检查目标空间,并理解失败后的状态。
Windows 与 Unix 还有覆盖差异:Unix 上 os.rename() 会直接覆盖目标文件;Windows 上如果目标存在,os.rename() 会失败。shutil.move() 在 Windows 上会先删除目标再重命名,这可能产生数据丢失窗口。若需要跨平台原子覆盖,应确保目标不存在,或使用 os.replace()。
五、归档与路径:make_archive() 的陷阱
make_archive() 支持 zip、tar、gztar、bztar、xztar 等格式,底层调用 zipfile 或 tarfile。默认不包含空目录(ZIP),不保留 Unix 权限(除非使用 tar 格式)。要精确控制,应直接使用 zipfile 或 tarfile。
- import shutil
- shutil.make_archive('backup', 'zip', 'my_project')
复制代码
路径陷阱示例:
- import shutil
- shutil.make_archive('backup', 'zip', root_dir='/home/user/project')
复制代码
生成的 ZIP 中,文件路径相对于 root_dir。如果 root_dir 是相对路径,可能产生意外的目录结构。始终使用绝对路径,并明确 base_dir 参数。归档需要保留元数据时,优先使用 tar 格式:
- import shutil
- shutil.make_archive('backup', 'gztar', root_dir='/home/user/project')
复制代码
tar 格式保留 Unix 权限和符号链接,zip 则不保留。如果确实需要 ZIP,使用 zipfile 手动控制。
六、磁盘空间、原子替换与大文件进度
陷阱 8:复制前忽略磁盘空间检查。大文件复制到小分区时,可能复制到一半磁盘满,留下不完整文件。可先用 shutil.disk_usage() 预检:
- import os
- import shutil
- usage = shutil.disk_usage('/mnt/small_disk')
- if os.path.getsize('huge_file') > usage.free:
- raise OSError('磁盘空间不足')
复制代码
原子替换文件可用临时文件 + os.replace():
- import os
- import shutil
- import tempfile
- def atomic_replace(src, dst):
- dir_name = os.path.dirname(os.path.abspath(dst))
- fd, tmp = tempfile.mkstemp(dir=dir_name)
- try:
- with os.fdopen(fd, 'wb') as f:
- with open(src, 'rb') as src_f:
- shutil.copyfileobj(src_f, f)
- f.flush()
- os.fsync(f.fileno())
- os.replace(tmp, dst)
- except:
- os.unlink(tmp)
- raise
复制代码
大文件复制进度可用 copyfileobj() 的自定义缓冲区思路,手动循环读取并统计:
- import os
- def copy_with_progress(src, dst, buffer_size=1024 * 1024):
- with open(src, 'rb') as fsrc, open(dst, 'wb') as fdst:
- total = os.path.getsize(src)
- copied = 0
- while True:
- buf = fsrc.read(buffer_size)
- if not buf:
- break
- fdst.write(buf)
- copied += len(buf)
- print(f'进度: {copied / total * 100:.1f}%')
复制代码
七、调试与验证技巧
原文建议从这些角度验证 shutil 操作:
- 检查复制结果:用 os.stat() 对比源和目标的权限、时间戳。
- 测试 rmtree 的边界:包含只读文件、符号链接、空目录的树。
- 验证 move 的原子性:在跨设备场景下测试,确认源文件在失败时未被删除。
- 使用 disk_usage 预检空间。
- 在 CI 中测试 Windows/Linux 差异。
- 记录操作的详细信息:在关键操作前后记录文件状态,便于排查。
- 使用 shutil.which() 检查可执行文件,而不是手动拼路径。
八、最佳实践总结
- 备份文件用 copy2(),不是 copy()。
- 复制目录用 copytree(),配合 dirs_exist_ok=True 和 copy_function=shutil.copy2。
- 删除目录前确认目标,rmtree() 不可逆。
- 为 rmtree() 提供只读文件处理回调,确保清理可靠。
- 跨文件系统移动前检查磁盘空间,理解 move 的非原子性。
- 归档保留元数据用 tar 格式,需要 ZIP 则用 zipfile 手动控制。
- 原子替换用临时文件 + os.replace()。
- 使用 shutil.ignore_patterns() 排除缓存和版本控制文件。
- 大文件复制时考虑进度反馈和超时处理。
- 在所有关键操作前后记录日志,便于审计和排查。
结语:shutil 的每个函数都有微妙语义差异和平台行为。copy() 与 copy2() 的差别决定备份是否保留权限和时间戳;copytree() 的 dirs_exist_ok 决定能否合并目录;rmtree() 的错误回调决定能否清理顽固文件。把这些参数和行为固定到脚本模板里,文件操作会稳定得多。 |