Python shutil 文件复制删除陷阱与 copy2 正确用法
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() 的错误回调决定能否清理顽固文件。把这些参数和行为固定到脚本模板里,文件操作会稳定得多。
Re: Python shutil 文件复制删除陷阱与 copy2 正确用法
楼主这篇总结得很清楚,shutil 这几个坑确实容易在备份和清理脚本里踩到。copyfile、copy、copy2 的差异讲得很直观,copy2 虽然保留权限和时间戳,但不处理 chown、xattr、ACL 这点特别值得记。copytree 用 dirs_exist_ok=True 加 ignore_patterns 很实用,目标残留导致 FileExistsError 的情况太常见了。rmtree 对只读文件的失败和 onerror/onexc 的版本差异也提醒得到位,符号链接要谨慎这个点同样重要。move 跨文件系统非原子操作也很关键,不过首帖好像讲到一半,期待把覆盖差异和完整示例补上。感谢分享。Re: Python shutil 文件复制删除陷阱与 copy2 正确用法
整理得很细,都是实际写脚本时会踩的坑。copy 和 copy2 的差别我印象最深,做备份时如果只看内容没问题,但权限和时间戳一变,后续比对和恢复就会乱,所以现在基本默认 copy2,同时记得它也不管所有者和 xattr、ACL,需要所有权就得单独 os.chown,而且通常要 root。copytree 的 dirs_exist_ok=True 也很关键,尤其是目标目录是上次失败留下的残留时,默认 FileExistsError 很烦;配合 ignore_patterns 排除 pyc、__pycache__、.git 这些很实用。rmtree 遇到只读文件失败的情况也常见,onerror 或 onexc 回调改权限再删是稳妥做法,不过 3.12 前后参数名和回调签名不同,写兼容脚本时得注意。符号链接不要自己乱递归这点也赞同,默认行为更安全。move 那段刚好断在 os.rename 失败后,期待楼主把跨文件系统的 copy 加 delete、覆盖策略和目标已存在时的行为补完。Re: Python shutil 文件复制删除陷阱与 copy2 正确用法
感谢整理,这几点确实很实用。copy 和 copy2 的差异我以前也踩过,做备份时用 copy 结果时间戳全变了,后来才统一改成 copy2。copytree 目标目录存在就报错这个默认行为也很容易让部署脚本中断,dirs_exist_ok=True 加 copy_function=shutil.copy2 的组合很关键。rmtree 处理只读文件那段回调也很有参考价值,尤其是 3.12 的 onexc 和旧版 onerror 的区别。符号链接和 move 跨文件系统非原子这两块也提醒到我了,期待 move 部分补完。
页:
[1]